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(grabbedEl.GetRawText()) + : null; + + return (instanceName, itemCount, items, searchType, grabbedItems); + } + catch (JsonException) + { + return ("Unknown", 0, [], SeekerSearchType.Proactive, null); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs new file mode 100644 index 00000000..562c8e28 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SeekerConfigController.cs @@ -0,0 +1,192 @@ +using Cleanuparr.Api.Features.Seeker.Contracts.Requests; +using Cleanuparr.Shared.Helpers; +using Cleanuparr.Api.Features.Seeker.Contracts.Responses; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Cleanuparr.Api.Features.Seeker.Controllers; + +[ApiController] +[Route("api/configuration")] +[Authorize] +public sealed class SeekerConfigController : ControllerBase +{ + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + + public SeekerConfigController( + ILogger logger, + DataContext dataContext, + IJobManagementService jobManagementService) + { + _logger = logger; + _dataContext = dataContext; + _jobManagementService = jobManagementService; + } + + [HttpGet("seeker")] + public async Task GetSeekerConfig() + { + var config = await _dataContext.SeekerConfigs + .AsNoTracking() + .FirstAsync(); + + // Get all Sonarr/Radarr instances with their seeker configs + var arrInstances = await _dataContext.ArrInstances + .AsNoTracking() + .Include(a => a.ArrConfig) + .Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr) + .ToListAsync(); + + var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet(); + var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs + .AsNoTracking() + .Where(s => arrInstanceIds.Contains(s.ArrInstanceId)) + .ToListAsync(); + + var instanceResponses = arrInstances.Select(instance => + { + var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id); + return new SeekerInstanceConfigResponse + { + ArrInstanceId = instance.Id, + InstanceName = instance.Name, + InstanceType = instance.ArrConfig.Type, + Enabled = seekerConfig?.Enabled ?? false, + SkipTags = seekerConfig?.SkipTags ?? [], + LastProcessedAt = seekerConfig?.LastProcessedAt, + ArrInstanceEnabled = instance.Enabled, + ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3, + MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7, + }; + }).ToList(); + + var response = new SeekerConfigResponse + { + SearchEnabled = config.SearchEnabled, + SearchInterval = config.SearchInterval, + ProactiveSearchEnabled = config.ProactiveSearchEnabled, + SelectionStrategy = config.SelectionStrategy, + MonitoredOnly = config.MonitoredOnly, + UseCutoff = config.UseCutoff, + UseCustomFormatScore = config.UseCustomFormatScore, + UseRoundRobin = config.UseRoundRobin, + PostReleaseGraceHours = config.PostReleaseGraceHours, + Instances = instanceResponses, + }; + + return Ok(response); + } + + [HttpPut("seeker")] + public async Task UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request) + { + if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30))) + { + return StatusCode(503, "Database is busy, please try again"); + } + + try + { + var config = await _dataContext.SeekerConfigs.FirstAsync(); + + ushort previousInterval = config.SearchInterval; + bool previousUseCustomFormatScore = config.UseCustomFormatScore; + bool previousSearchEnabled = config.SearchEnabled; + bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled; + + request.ApplyTo(config); + config.Validate(); + + if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled)) + { + throw new Domain.Exceptions.ValidationException( + "At least one instance must be enabled when proactive search is enabled"); + } + + // Sync instance configs + var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs.ToListAsync(); + + foreach (var instanceReq in request.Instances) + { + var existing = existingInstanceConfigs + .FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId); + + if (existing is not null) + { + existing.Enabled = instanceReq.Enabled; + existing.SkipTags = instanceReq.SkipTags; + existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit; + existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays; + } + else + { + _dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = instanceReq.ArrInstanceId, + Enabled = instanceReq.Enabled, + SkipTags = instanceReq.SkipTags, + ActiveDownloadLimit = instanceReq.ActiveDownloadLimit, + MinCycleTimeDays = instanceReq.MinCycleTimeDays, + }); + } + } + + await _dataContext.SaveChangesAsync(); + + // Update Quartz trigger if SearchInterval changed + if (config.SearchInterval != previousInterval) + { + _logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule", + previousInterval, config.SearchInterval); + await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression()); + } + + // Toggle CustomFormatScoreSyncer job when UseCustomFormatScore changes + if (config.UseCustomFormatScore != previousUseCustomFormatScore) + { + if (config.UseCustomFormatScore) + { + _logger.LogInformation("UseCustomFormatScore enabled, starting CustomFormatScoreSyncer job"); + await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron); + await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer); + } + else + { + _logger.LogInformation("UseCustomFormatScore disabled, stopping CustomFormatScoreSyncer job"); + await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer); + } + } + + // Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active + if (previousUseCustomFormatScore && config.UseCustomFormatScore) + { + bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled; + bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled; + + if (searchJustEnabled || proactiveJustEnabled) + { + _logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer"); + await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer); + } + } + + return Ok(new { Message = "Seeker configuration updated successfully" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save Seeker configuration"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } +} diff --git a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs index ee32344a..7e3cdbef 100644 --- a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs +++ b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs @@ -6,6 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Quartz; @@ -100,12 +102,17 @@ public class BackgroundJobManager : IHostedService BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs .AsNoTracking() .FirstAsync(cancellationToken); - + SeekerConfig seekerConfig = await dataContext.SeekerConfigs + .AsNoTracking() + .FirstAsync(cancellationToken); + // Always register jobs, regardless of enabled status await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken); await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken); await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken); await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken); + await RegisterSeekerJob(seekerConfig, cancellationToken); + await RegisterCustomFormatScoreSyncJob(seekerConfig, cancellationToken); } /// @@ -171,6 +178,30 @@ public class BackgroundJobManager : IHostedService } } + /// + /// Registers the Seeker job with a trigger based on SearchInterval. + /// The Seeker is always running. + /// + public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default) + { + await AddJobWithoutTrigger(cancellationToken); + await AddTriggersForJob(config.ToCronExpression(), cancellationToken); + } + + /// + /// Registers the CustomFormatScoreSyncer job. Only adds triggers when UseCustomFormatScore is enabled. + /// Runs every 30 minutes to sync custom format scores from arr instances. + /// + public async Task RegisterCustomFormatScoreSyncJob(SeekerConfig config, CancellationToken cancellationToken = default) + { + await AddJobWithoutTrigger(cancellationToken); + + if (config.UseCustomFormatScore) + { + await AddTriggersForJob(Constants.CustomFormatScoreSyncerCron, cancellationToken); + } + } + /// /// Helper method to add triggers for an existing job. /// @@ -204,7 +235,11 @@ public class BackgroundJobManager : IHostedService throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours"); } - if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit) + if (typeof(T) == typeof(SeekerJob) && triggerValue < Constants.SeekerMinLimit) + { + throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes"); + } + else if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit) { throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds"); } diff --git a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs index 40264f40..d3d4fad2 100644 --- a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs +++ b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs @@ -51,7 +51,7 @@ public sealed class GenericJob : IJob await BroadcastJobStatus(hubContext, jobManagementService, jobType, false); var handler = scope.ServiceProvider.GetRequiredService(); - await handler.ExecuteAsync(); + await handler.ExecuteAsync(context.CancellationToken); status = JobRunStatus.Completed; await BroadcastJobStatus(hubContext, jobManagementService, jobType, true); diff --git a/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs index 012b49b3..0aecb1d3 100644 --- a/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs +++ b/code/backend/Cleanuparr.Application/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler _dryRunInterceptor = dryRunInterceptor; } - public async Task ExecuteAsync() + public async Task ExecuteAsync(CancellationToken cancellationToken = default) { BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs .AsNoTracking() @@ -73,7 +73,7 @@ public sealed class BlacklistSynchronizer : IHandler .AsNoTracking() .Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent) .ToListAsync(); - + if (qBittorrentClients.Count is 0) { _logger.LogDebug("No enabled qBittorrent clients found for blacklist sync"); diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrCommandStatus.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrCommandStatus.cs new file mode 100644 index 00000000..16288b68 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrCommandStatus.cs @@ -0,0 +1,3 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record ArrCommandStatus(long Id, string Status, string? Message); diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs new file mode 100644 index 00000000..8cd0358b --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrEpisodeFile.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record ArrEpisodeFile +{ + public long Id { get; init; } + + public bool QualityCutoffNotMet { get; init; } + + public int CustomFormatScore { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs new file mode 100644 index 00000000..ca337fa4 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record ArrQualityProfile +{ + public int Id { get; init; } + + public string Name { get; init; } = string.Empty; + + public int CutoffFormatScore { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/MediaFileScore.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/MediaFileScore.cs new file mode 100644 index 00000000..7e018eec --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/MediaFileScore.cs @@ -0,0 +1,11 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +/// +/// Represents the custom format score data from a movie/episode file API response +/// +public sealed record MediaFileScore +{ + public long Id { get; init; } + + public int CustomFormatScore { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/MovieFileInfo.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/MovieFileInfo.cs new file mode 100644 index 00000000..21756452 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/MovieFileInfo.cs @@ -0,0 +1,8 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record MovieFileInfo +{ + public long Id { get; init; } + + public bool QualityCutoffNotMet { get; init; } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs index 7ef1807e..6117a051 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/Queue/QueueRecord.cs @@ -37,4 +37,5 @@ public sealed record QueueRecord public required string DownloadId { get; init; } public required string Protocol { get; init; } public required long Id { get; init; } + public long SizeLeft { get; init; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs new file mode 100644 index 00000000..e34e75cf --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableEpisode.cs @@ -0,0 +1,18 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record SearchableEpisode +{ + public long Id { get; init; } + + public int SeasonNumber { get; init; } + + public int EpisodeNumber { get; init; } + + public bool Monitored { get; init; } + + public DateTime? AirDateUtc { get; init; } + + public bool HasFile { get; init; } + + public long EpisodeFileId { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs new file mode 100644 index 00000000..2b49ec1c --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableMovie.cs @@ -0,0 +1,28 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record SearchableMovie +{ + public long Id { get; init; } + + public string Title { get; init; } = string.Empty; + + public bool Monitored { get; init; } + + public bool HasFile { get; init; } + + public MovieFileInfo? MovieFile { get; init; } + + public List Tags { get; init; } = []; + + public int QualityProfileId { get; init; } + + public string Status { get; init; } = string.Empty; + + public DateTime? Added { get; init; } + + public DateTime? DigitalRelease { get; init; } + + public DateTime? PhysicalRelease { get; init; } + + public DateTime? InCinemas { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs new file mode 100644 index 00000000..bacd7fc6 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchableSeries.cs @@ -0,0 +1,29 @@ +namespace Cleanuparr.Domain.Entities.Arr; + +public sealed record SearchableSeries +{ + public long Id { get; init; } + + public string Title { get; init; } = string.Empty; + + public int QualityProfileId { get; init; } + + public bool Monitored { get; init; } + + public List Tags { get; init; } = []; + + public DateTime? Added { get; init; } + + public string Status { get; init; } = string.Empty; + + public SeriesStatistics? Statistics { get; init; } +} + +public sealed record SeriesStatistics +{ + public int EpisodeFileCount { get; init; } + + public int EpisodeCount { get; init; } + + public double PercentOfEpisodes { get; init; } +} diff --git a/code/backend/Cleanuparr.Domain/Enums/EventType.cs b/code/backend/Cleanuparr.Domain/Enums/EventType.cs index 6040e058..4e876880 100644 --- a/code/backend/Cleanuparr.Domain/Enums/EventType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/EventType.cs @@ -10,5 +10,6 @@ public enum EventType QueueItemDeleted, DownloadCleaned, CategoryChanged, - DownloadMarkedForDeletion + DownloadMarkedForDeletion, + SearchTriggered, } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Domain/Enums/JobType.cs b/code/backend/Cleanuparr.Domain/Enums/JobType.cs index 8307fdf0..fd8e70da 100644 --- a/code/backend/Cleanuparr.Domain/Enums/JobType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/JobType.cs @@ -6,4 +6,6 @@ public enum JobType MalwareBlocker, DownloadCleaner, BlacklistSynchronizer, + Seeker, + CustomFormatScoreSyncer, } diff --git a/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs b/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs index bc239859..4b82256c 100644 --- a/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs @@ -9,5 +9,6 @@ public enum NotificationEventType SlowTimeStrike, QueueItemDeleted, DownloadCleaned, - CategoryChanged + CategoryChanged, + SearchTriggered } diff --git a/code/backend/Cleanuparr.Domain/Enums/SearchCommandStatus.cs b/code/backend/Cleanuparr.Domain/Enums/SearchCommandStatus.cs new file mode 100644 index 00000000..58b23255 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SearchCommandStatus.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SearchCommandStatus +{ + Pending, + Started, + Completed, + Failed, + TimedOut +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SeekerSearchType.cs b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchType.cs new file mode 100644 index 00000000..7c48952b --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchType.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeekerSearchType +{ + Proactive, + Replacement +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs b/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs new file mode 100644 index 00000000..39a1c243 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs @@ -0,0 +1,43 @@ +namespace Cleanuparr.Domain.Enums; + +public enum SelectionStrategy +{ + /// + /// Weighted random selection combining search recency and add date. + /// Items that are both recently added and haven't been searched + /// get the highest priority. Best all-around strategy for mixed libraries. + /// + BalancedWeighted, + + /// + /// Deterministic selection of items with the oldest (or no) search history first. + /// Provides systematic, sequential coverage of your entire library. + /// + OldestSearchFirst, + + /// + /// Weighted random selection based on search recency. + /// Items that haven't been searched recently are ranked higher and more likely to be selected, + /// while recently-searched items still have a chance proportional to their rank. + /// + OldestSearchWeighted, + + /// + /// Deterministic selection of the most recently added items first. + /// Always picks the newest content in your library. + /// + NewestFirst, + + /// + /// Weighted random selection based on when items were added. + /// Recently added items are ranked higher and more likely to be selected, + /// while older items still have a chance proportional to their rank. + /// + NewestWeighted, + + /// + /// Pure random selection with no weighting or bias. + /// Every eligible item has an equal chance of being selected. + /// + Random, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SeriesSearchType.cs b/code/backend/Cleanuparr.Domain/Enums/SeriesSearchType.cs index bca504aa..71e3334b 100644 --- a/code/backend/Cleanuparr.Domain/Enums/SeriesSearchType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/SeriesSearchType.cs @@ -1,8 +1,8 @@ -namespace Cleanuparr.Domain.Enums; +namespace Cleanuparr.Domain.Enums; public enum SeriesSearchType { Episode, Season, Series -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Domain/Exceptions/ValidationException.cs b/code/backend/Cleanuparr.Domain/Exceptions/ValidationException.cs index 0fe5d7c5..c7989aa6 100644 --- a/code/backend/Cleanuparr.Domain/Exceptions/ValidationException.cs +++ b/code/backend/Cleanuparr.Domain/Exceptions/ValidationException.cs @@ -9,4 +9,8 @@ public sealed class ValidationException : Exception public ValidationException(string message) : base(message) { } + + public ValidationException(string message, Exception inner) : base(message, inner) + { + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs index da3f4006..4f8932df 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Features.Context; @@ -578,4 +579,220 @@ public class EventPublisherTests : IDisposable } #endregion + + #region PublishSearchTriggered Tests + + [Fact] + public async Task PublishSearchTriggered_SavesEventWithCorrectType() + { + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive); + + // Assert + var savedEvent = await _context.Events.FirstOrDefaultAsync(); + Assert.NotNull(savedEvent); + Assert.Equal(EventType.SearchTriggered, savedEvent.EventType); + Assert.Equal(EventSeverity.Information, savedEvent.Severity); + } + + [Fact] + public async Task PublishSearchTriggered_SetsSearchStatusToPending() + { + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Assert + var savedEvent = await _context.Events.FirstOrDefaultAsync(); + Assert.NotNull(savedEvent); + Assert.Equal(SearchCommandStatus.Pending, savedEvent.SearchStatus); + } + + [Fact] + public async Task PublishSearchTriggered_SetsCycleId() + { + // Arrange + var cycleId = Guid.NewGuid(); + + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleId); + + // Assert + var savedEvent = await _context.Events.FirstOrDefaultAsync(); + Assert.NotNull(savedEvent); + Assert.Equal(cycleId, savedEvent.CycleId); + } + + [Fact] + public async Task PublishSearchTriggered_ReturnsEventId() + { + // Act + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Assert + Assert.NotEqual(Guid.Empty, eventId); + var savedEvent = await _context.Events.FindAsync(eventId); + Assert.NotNull(savedEvent); + } + + [Fact] + public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData() + { + // Act + await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement); + + // Assert + var savedEvent = await _context.Events.FirstOrDefaultAsync(); + Assert.NotNull(savedEvent); + Assert.NotNull(savedEvent.Data); + Assert.Contains("Series A", savedEvent.Data); + Assert.Contains("Series B", savedEvent.Data); + Assert.Contains("Replacement", savedEvent.Data); + Assert.Contains("Sonarr-1", savedEvent.Data); + } + + [Fact] + public async Task PublishSearchTriggered_NotifiesSignalRClients() + { + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Assert + _clientProxyMock.Verify(c => c.SendCoreAsync( + "EventReceived", + It.Is(args => args.Length == 1 && args[0] is AppEvent), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task PublishSearchTriggered_SendsNotification() + { + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive); + + // Assert + _notificationPublisherMock.Verify( + n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items() + { + // Arrange + var items = new[] { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7" }; + + // Act + await _publisher.PublishSearchTriggered("Radarr-1", 7, items, SeekerSearchType.Proactive); + + // Assert + var savedEvent = await _context.Events.FirstOrDefaultAsync(); + Assert.NotNull(savedEvent); + Assert.Contains("+2 more", savedEvent.Message); + } + + #endregion + + #region PublishSearchCompleted Tests + + [Fact] + public async Task PublishSearchCompleted_UpdatesEventStatus() + { + // Arrange — create a search event first + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Act + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); + + // Assert + var updatedEvent = await _context.Events.FindAsync(eventId); + Assert.NotNull(updatedEvent); + Assert.Equal(SearchCommandStatus.Completed, updatedEvent.SearchStatus); + } + + [Fact] + public async Task PublishSearchCompleted_SetsCompletedAt() + { + // Arrange + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Act + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); + + // Assert + var updatedEvent = await _context.Events.FindAsync(eventId); + Assert.NotNull(updatedEvent); + Assert.NotNull(updatedEvent.CompletedAt); + } + + [Fact] + public async Task PublishSearchCompleted_MergesResultDataIntoExistingData() + { + // Arrange + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } }; + + // Act + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData); + + // Assert + var updatedEvent = await _context.Events.FindAsync(eventId); + Assert.NotNull(updatedEvent); + Assert.NotNull(updatedEvent.Data); + // Original data should still be present + Assert.Contains("Movie A", updatedEvent.Data); + // Merged result data should be present + Assert.Contains("GrabbedItems", updatedEvent.Data); + Assert.Contains("Movie A (2024)", updatedEvent.Data); + } + + [Fact] + public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData() + { + // Arrange + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + var originalEvent = await _context.Events.FindAsync(eventId); + string? originalData = originalEvent!.Data; + + // Act + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); + + // Assert + var updatedEvent = await _context.Events.FindAsync(eventId); + Assert.NotNull(updatedEvent); + Assert.Equal(originalData, updatedEvent.Data); + } + + [Fact] + public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns() + { + // Act — use a non-existent event ID + await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed); + + // Assert — should not throw, and the log warning is the important behavior + // (no exception thrown is the assertion) + var eventCount = await _context.Events.CountAsync(); + Assert.Equal(0, eventCount); + } + + [Fact] + public async Task PublishSearchCompleted_NotifiesSignalRClients() + { + // Arrange + Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + + // Reset mock to only capture the completion call + _clientProxyMock.Invocations.Clear(); + + // Act + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); + + // Assert + _clientProxyMock.Verify(c => c.SendCoreAsync( + "EventReceived", + It.Is(args => args.Length == 1 && args[0] is AppEvent), + It.IsAny()), Times.Once); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs deleted file mode 100644 index 507665b1..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/Consumers/DownloadHunterConsumerTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Cleanuparr.Domain.Entities.Arr.Queue; -using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; -using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; -using MassTransit; -using Microsoft.Extensions.Logging; -using Moq; -using Xunit; - -namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers; - -public class DownloadHunterConsumerTests -{ - private readonly Mock>> _loggerMock; - private readonly Mock _downloadHunterMock; - private readonly DownloadHunterConsumer _consumer; - - public DownloadHunterConsumerTests() - { - _loggerMock = new Mock>>(); - _downloadHunterMock = new Mock(); - _consumer = new DownloadHunterConsumer(_loggerMock.Object, _downloadHunterMock.Object); - } - - #region Consume Tests - - [Fact] - public async Task Consume_CallsHuntDownloadsAsync() - { - // Arrange - var request = CreateHuntRequest(); - var contextMock = CreateConsumeContextMock(request); - - _downloadHunterMock - .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) - .Returns(Task.CompletedTask); - - // Act - await _consumer.Consume(contextMock.Object); - - // Assert - _downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once); - } - - [Fact] - public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow() - { - // Arrange - var request = CreateHuntRequest(); - var contextMock = CreateConsumeContextMock(request); - - _downloadHunterMock - .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) - .ThrowsAsync(new Exception("Hunt failed")); - - // Act - Should not throw - await _consumer.Consume(contextMock.Object); - - // Assert - _loggerMock.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("failed to search for replacement")), - It.IsAny(), - It.IsAny>()), - Times.Once); - } - - [Fact] - public async Task Consume_PassesCorrectRequestToHunter() - { - // Arrange - var request = CreateHuntRequest(); - var contextMock = CreateConsumeContextMock(request); - DownloadHuntRequest? capturedRequest = null; - - _downloadHunterMock - .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) - .Callback>(r => capturedRequest = r) - .Returns(Task.CompletedTask); - - // Act - await _consumer.Consume(contextMock.Object); - - // Assert - Assert.NotNull(capturedRequest); - Assert.Equal(request.InstanceType, capturedRequest.InstanceType); - Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id); - } - - [Fact] - public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly() - { - // Arrange - var request = new DownloadHuntRequest - { - InstanceType = InstanceType.Lidarr, - Instance = CreateArrInstance(), - SearchItem = new SearchItem { Id = 999 }, - Record = CreateQueueRecord(), - JobRunId = Guid.NewGuid() - }; - var contextMock = CreateConsumeContextMock(request); - - _downloadHunterMock - .Setup(h => h.HuntDownloadsAsync(It.IsAny>())) - .Returns(Task.CompletedTask); - - // Act - await _consumer.Consume(contextMock.Object); - - // Assert - _downloadHunterMock.Verify(h => h.HuntDownloadsAsync( - It.Is>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once); - } - - #endregion - - #region Helper Methods - - private static DownloadHuntRequest CreateHuntRequest() - { - return new DownloadHuntRequest - { - InstanceType = InstanceType.Radarr, - Instance = CreateArrInstance(), - SearchItem = new SearchItem { Id = 123 }, - Record = CreateQueueRecord(), - JobRunId = Guid.NewGuid() - }; - } - - private static ArrInstance CreateArrInstance() - { - return new ArrInstance - { - Name = "Test Instance", - Url = new Uri("http://radarr.local"), - ApiKey = "test-api-key" - }; - } - - private static QueueRecord CreateQueueRecord() - { - return new QueueRecord - { - Id = 1, - Title = "Test Record", - Protocol = "torrent", - DownloadId = "ABC123" - }; - } - - private static Mock>> CreateConsumeContextMock(DownloadHuntRequest message) - { - var mock = new Mock>>(); - mock.Setup(c => c.Message).Returns(message); - return mock; - } - - #endregion -} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs deleted file mode 100644 index 5367de53..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadHunter/DownloadHunterTests.cs +++ /dev/null @@ -1,313 +0,0 @@ -using Cleanuparr.Domain.Entities.Arr.Queue; -using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.Arr.Interfaces; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; -using Cleanuparr.Persistence; -using Cleanuparr.Persistence.Models.Configuration.Arr; -using Cleanuparr.Persistence.Models.Configuration.General; -using Cleanuparr.Shared.Helpers; -using Data.Models.Arr; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Time.Testing; -using Moq; -using Xunit; - -namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter; - -public class DownloadHunterTests : IDisposable -{ - private readonly DataContext _dataContext; - private readonly Mock _arrClientFactoryMock; - private readonly Mock _arrClientMock; - private readonly FakeTimeProvider _fakeTimeProvider; - private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter; - private readonly SqliteConnection _connection; - - public DownloadHunterTests() - { - // Use SQLite in-memory with shared connection to support complex types - _connection = new SqliteConnection("DataSource=:memory:"); - _connection.Open(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - _dataContext = new DataContext(options); - _dataContext.Database.EnsureCreated(); - - _arrClientFactoryMock = new Mock(); - _arrClientMock = new Mock(); - _fakeTimeProvider = new FakeTimeProvider(); - - _arrClientFactoryMock - .Setup(f => f.GetClient(It.IsAny(), It.IsAny())) - .Returns(_arrClientMock.Object); - - _downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter( - _dataContext, - _arrClientFactoryMock.Object, - _fakeTimeProvider - ); - } - - public void Dispose() - { - _dataContext.Dispose(); - _connection.Dispose(); - } - - #region HuntDownloadsAsync - Search Disabled Tests - - [Fact] - public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient() - { - // Arrange - await SetupGeneralConfig(searchEnabled: false); - var request = CreateHuntRequest(); - - // Act - await _downloadHunter.HuntDownloadsAsync(request); - - // Assert - _arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny(), It.IsAny()), Times.Never); - _arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny(), It.IsAny>()), Times.Never); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately() - { - // Arrange - await SetupGeneralConfig(searchEnabled: false); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Assert - Should complete without needing to advance time - var completedTask = await Task.WhenAny(task, Task.Delay(100)); - Assert.Same(task, completedTask); - } - - #endregion - - #region HuntDownloadsAsync - Search Enabled Tests - - [Fact] - public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory() - { - // Arrange - await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds); - var request = CreateHuntRequest(); - - // Act - Start the task and advance time - var task = _downloadHunter.HuntDownloadsAsync(request); - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds)); - await task; - - // Assert - _arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType, It.IsAny()), Times.Once); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync() - { - // Arrange - await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds)); - await task; - - // Assert - _arrClientMock.Verify( - c => c.SearchItemsAsync( - request.Instance, - It.Is>(s => s.Contains(request.SearchItem))), - Times.Once); - } - - [Theory] - [InlineData(InstanceType.Sonarr)] - [InlineData(InstanceType.Radarr)] - [InlineData(InstanceType.Lidarr)] - [InlineData(InstanceType.Readarr)] - [InlineData(InstanceType.Whisparr)] - public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType) - { - // Arrange - await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds); - var request = CreateHuntRequest(instanceType); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds)); - await task; - - // Assert - _arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny()), Times.Once); - } - - #endregion - - #region HuntDownloadsAsync - Delay Tests - - [Fact] - public async Task HuntDownloadsAsync_WaitsForConfiguredDelay() - { - // Arrange - const ushort configuredDelay = 120; - await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Assert - Task should not complete before advancing time - Assert.False(task.IsCompleted); - - // Advance partial time - should still not complete - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1)); - await Task.Delay(10); // Give the task a chance to complete if it would - Assert.False(task.IsCompleted); - - // Advance remaining time - should now complete - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(1)); - await task; - Assert.True(task.IsCompletedSuccessfully); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay() - { - // Arrange - Set delay below minimum (simulating manual DB edit) - const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60) - await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Advance by the below-min value - should NOT complete because it should use default - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay)); - await Task.Delay(10); - Assert.False(task.IsCompleted); - - // Advance to default delay - should now complete - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay)); - await task; - Assert.True(task.IsCompletedSuccessfully); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay() - { - // Arrange - await SetupGeneralConfig(searchEnabled: true, searchDelay: 0); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Assert - Should not complete immediately - Assert.False(task.IsCompleted); - - // Advance to default delay - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds)); - await task; - Assert.True(task.IsCompletedSuccessfully); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay() - { - // Arrange - Set delay exactly at minimum - await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Advance by minimum - should complete - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds)); - await task; - Assert.True(task.IsCompletedSuccessfully); - } - - [Fact] - public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay() - { - // Arrange - Set delay above minimum - const ushort aboveMinDelay = 180; - await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay); - var request = CreateHuntRequest(); - - // Act - var task = _downloadHunter.HuntDownloadsAsync(request); - - // Advance by minimum - should NOT complete yet - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds)); - await Task.Delay(10); - Assert.False(task.IsCompleted); - - // Advance remaining time - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds)); - await task; - Assert.True(task.IsCompletedSuccessfully); - } - - #endregion - - #region Helper Methods - - private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds) - { - var generalConfig = new GeneralConfig - { - SearchEnabled = searchEnabled, - SearchDelay = searchDelay - }; - - _dataContext.GeneralConfigs.Add(generalConfig); - await _dataContext.SaveChangesAsync(); - } - - private static DownloadHuntRequest CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr) - { - return new DownloadHuntRequest - { - InstanceType = instanceType, - Instance = CreateArrInstance(), - SearchItem = new SearchItem { Id = 123 }, - Record = CreateQueueRecord(), - JobRunId = Guid.NewGuid() - }; - } - - private static ArrInstance CreateArrInstance() - { - return new ArrInstance - { - Name = "Test Instance", - Url = new Uri("http://arr.local"), - ApiKey = "test-api-key", - Version = 0 - }; - } - - private static QueueRecord CreateQueueRecord() - { - return new QueueRecord - { - Id = 1, - Title = "Test Record", - Protocol = "torrent", - DownloadId = "ABC123" - }; - } - - #endregion -} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index bf49bdb4..8744a7b9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -4,7 +4,6 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.ItemStriker; @@ -15,8 +14,8 @@ using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Data.Models.Arr; -using MassTransit; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -28,19 +27,18 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover; public class QueueItemRemoverTests : IDisposable { private readonly Mock> _loggerMock; - private readonly Mock _busMock; private readonly MemoryCache _memoryCache; private readonly Mock _arrClientFactoryMock; private readonly Mock _arrClientMock; private readonly EventPublisher _eventPublisher; private readonly EventsContext _eventsContext; + private readonly DataContext _dataContext; private readonly QueueItemRemover _queueItemRemover; private readonly Guid _jobRunId; public QueueItemRemoverTests() { _loggerMock = new Mock>(); - _busMock = new Mock(); _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); _arrClientFactoryMock = new Mock(); _arrClientMock = new Mock(); @@ -77,13 +75,16 @@ public class QueueItemRemoverTests : IDisposable Mock.Of(), dryRunInterceptorMock.Object); + // Create in-memory DataContext with seeded SeekerConfig + _dataContext = TestDataContextFactory.Create(); + _queueItemRemover = new QueueItemRemover( _loggerMock.Object, - _busMock.Object, _memoryCache, _arrClientFactoryMock.Object, _eventPublisher, - _eventsContext + _eventsContext, + _dataContext ); // Clear static RecurringHashes before each test @@ -94,6 +95,7 @@ public class QueueItemRemoverTests : IDisposable { _memoryCache.Dispose(); _eventsContext.Dispose(); + _dataContext.Dispose(); Striker.RecurringHashes.Clear(); } @@ -125,11 +127,10 @@ public class QueueItemRemoverTests : IDisposable } [Fact] - public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest() + public async Task RemoveQueueItemAsync_Success_AddsSearchQueueItem() { // Arrange var request = CreateRemoveRequest(); - DownloadHuntRequest? capturedRequest = null; _arrClientMock .Setup(c => c.DeleteQueueItemAsync( @@ -139,23 +140,15 @@ public class QueueItemRemoverTests : IDisposable It.IsAny())) .Returns(Task.CompletedTask); - _busMock - .Setup(b => b.Publish(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((r, _) => capturedRequest = r) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _busMock.Verify(b => b.Publish( - It.IsAny>(), - It.IsAny()), Times.Once); - - Assert.NotNull(capturedRequest); - Assert.Equal(request.InstanceType, capturedRequest!.InstanceType); - Assert.Equal(request.Instance, capturedRequest.Instance); - Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id); + var queueItems = await _dataContext.SearchQueue.ToListAsync(); + Assert.Single(queueItems); + Assert.Equal(request.Instance.Id, queueItems[0].ArrInstanceId); + Assert.Equal(request.SearchItem.Id, queueItems[0].ItemId); + Assert.Equal(request.Record.Title, queueItems[0].Title); } [Fact] @@ -212,7 +205,7 @@ public class QueueItemRemoverTests : IDisposable #region RemoveQueueItemAsync - Recurring Hash Tests [Fact] - public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest() + public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotAddSearchQueueItem() { // Arrange var request = CreateRemoveRequest(); @@ -231,9 +224,8 @@ public class QueueItemRemoverTests : IDisposable await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _busMock.Verify(b => b.Publish( - It.IsAny>(), - It.IsAny()), Times.Never); + var queueItems = await _dataContext.SearchQueue.ToListAsync(); + Assert.Empty(queueItems); } [Fact] @@ -260,7 +252,7 @@ public class QueueItemRemoverTests : IDisposable } [Fact] - public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest() + public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_AddsSearchQueueItem() { // Arrange var request = CreateRemoveRequest(); @@ -277,9 +269,8 @@ public class QueueItemRemoverTests : IDisposable await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _busMock.Verify(b => b.Publish( - It.IsAny>(), - It.IsAny()), Times.Once); + var queueItems = await _dataContext.SearchQueue.ToListAsync(); + Assert.Single(queueItems); } #endregion @@ -287,7 +278,7 @@ public class QueueItemRemoverTests : IDisposable #region RemoveQueueItemAsync - SkipSearch Tests [Fact] - public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest() + public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotAddSearchQueueItem() { // Arrange var request = CreateRemoveRequest(skipSearch: true); @@ -304,9 +295,8 @@ public class QueueItemRemoverTests : IDisposable await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _busMock.Verify(b => b.Publish( - It.IsAny>(), - It.IsAny()), Times.Never); + var queueItems = await _dataContext.SearchQueue.ToListAsync(); + Assert.Empty(queueItems); } [Fact] @@ -333,6 +323,36 @@ public class QueueItemRemoverTests : IDisposable #endregion + #region RemoveQueueItemAsync - SearchEnabled Tests + + [Fact] + public async Task RemoveQueueItemAsync_WhenSearchDisabled_DoesNotAddSearchQueueItem() + { + // Arrange + var seekerConfig = await _dataContext.SeekerConfigs.FirstAsync(); + seekerConfig.SearchEnabled = false; + await _dataContext.SaveChangesAsync(); + + var request = CreateRemoveRequest(); + + _arrClientMock + .Setup(c => c.DeleteQueueItemAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + var queueItems = await _dataContext.SearchQueue.ToListAsync(); + Assert.Empty(queueItems); + } + + #endregion + #region RemoveQueueItemAsync - HTTP Error Tests [Fact] @@ -496,10 +516,13 @@ public class QueueItemRemoverTests : IDisposable DeleteReason deleteReason = DeleteReason.Stalled, bool skipSearch = false) { + // Use an ArrInstance that exists in the DB to satisfy FK constraint on SearchQueueItem + var instance = GetOrCreateArrInstance(instanceType); + return new QueueItemRemoveRequest { InstanceType = instanceType, - Instance = CreateArrInstance(), + Instance = instance, SearchItem = new SearchItem { Id = 123 }, Record = CreateQueueRecord(), RemoveFromClient = removeFromClient, @@ -509,13 +532,16 @@ public class QueueItemRemoverTests : IDisposable }; } - private static ArrInstance CreateArrInstance() + private ArrInstance GetOrCreateArrInstance(InstanceType instanceType) { - return new ArrInstance + return instanceType switch { - Name = "Test Instance", - Url = new Uri("http://arr.local"), - ApiKey = "test-api-key" + InstanceType.Sonarr => TestDataContextFactory.AddSonarrInstance(_dataContext), + InstanceType.Radarr => TestDataContextFactory.AddRadarrInstance(_dataContext), + InstanceType.Lidarr => TestDataContextFactory.AddLidarrInstance(_dataContext), + InstanceType.Readarr => TestDataContextFactory.AddReadarrInstance(_dataContext), + InstanceType.Whisparr => TestDataContextFactory.AddWhisparrInstance(_dataContext), + _ => TestDataContextFactory.AddSonarrInstance(_dataContext), }; } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs new file mode 100644 index 00000000..405552c7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs @@ -0,0 +1,868 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Hubs; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using CustomFormatScoreSyncer = Cleanuparr.Infrastructure.Features.Jobs.CustomFormatScoreSyncer; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs; + +[Collection(JobHandlerCollection.Name)] +public class CustomFormatScoreSyncerTests : IDisposable +{ + private readonly JobHandlerFixture _fixture; + private readonly Mock> _logger; + private readonly Mock _radarrClient; + private readonly Mock _sonarrClient; + private readonly Mock> _hubContext; + + public CustomFormatScoreSyncerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = new Mock>(); + _radarrClient = new Mock(); + _sonarrClient = new Mock(); + _hubContext = new Mock>(); + + var mockClients = new Mock(); + var mockClientProxy = new Mock(); + mockClients.Setup(c => c.All).Returns(mockClientProxy.Object); + _hubContext.Setup(h => h.Clients).Returns(mockClients.Object); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + private CustomFormatScoreSyncer CreateSut() + { + return new CustomFormatScoreSyncer( + _logger.Object, + _fixture.DataContext, + _radarrClient.Object, + _sonarrClient.Object, + _fixture.TimeProvider, + _hubContext.Object + ); + } + + #region ExecuteAsync Tests + + [Fact] + public async Task ExecuteAsync_WhenCustomFormatScoreDisabled_ReturnsEarly() + { + // Arrange — UseCustomFormatScore is false by default in seed data + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = false; + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no API calls made + _radarrClient.Verify( + x => x.GetAllMoviesAsync(It.IsAny()), + Times.Never); + _sonarrClient.Verify( + x => x.GetAllSeriesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WhenNoEnabledInstances_ReturnsEarly() + { + // Arrange — enable CF scoring but add no SeekerInstanceConfigs + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no API calls + _radarrClient.Verify( + x => x.GetAllMoviesAsync(It.IsAny()), + Times.Never); + _sonarrClient.Verify( + x => x.GetAllSeriesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_SyncsRadarrMovieScores() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + // Mock quality profiles + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Mock movies with files + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, + Title = "Test Movie", + HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, + Status = "released", + Monitored = true + } + ]); + + // Mock file scores + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is>(ids => ids.Contains(100)))) + .ReturnsAsync(new Dictionary { { 100, 250 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — CF score entry was saved + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + + var entry = entries[0]; + Assert.Equal(radarrInstance.Id, entry.ArrInstanceId); + Assert.Equal(10, entry.ExternalItemId); + Assert.Equal(250, entry.CurrentScore); + Assert.Equal(500, entry.CutoffScore); + Assert.Equal("HD", entry.QualityProfileName); + Assert.Equal(InstanceType.Radarr, entry.ItemType); + Assert.True(entry.IsMonitored); + + // Initial history entry should also be created + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + Assert.Equal(250, history[0].Score); + } + + [Fact] + public async Task ExecuteAsync_RecordsHistoryOnScoreChange() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing CF score entry with a different score + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Test Movie", + FileId = 100, + CurrentScore = 200, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + // Mock quality profiles + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Mock movies — same movie but score changed from 200 to 350 + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, + Title = "Test Movie", + HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, + Status = "released", + Monitored = true + } + ]); + + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is>(ids => ids.Contains(100)))) + .ReturnsAsync(new Dictionary { { 100, 350 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — existing entry should be updated + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(350, entries[0].CurrentScore); + + // History entry should be created because score changed (200 -> 350) + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + Assert.Equal(350, history[0].Score); + Assert.Equal(InstanceType.Radarr, history[0].ItemType); + } + + [Fact] + public async Task ExecuteAsync_TracksUnmonitoredMovie() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, + Title = "Unmonitored Movie", + HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, + Status = "released", + Monitored = false + } + ]); + + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is>(ids => ids.Contains(100)))) + .ReturnsAsync(new Dictionary { { 100, 250 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — entry should be saved with IsMonitored = false + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.False(entries[0].IsMonitored); + } + + [Fact] + public async Task ExecuteAsync_UpdatesMonitoredStatusOnSync() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing entry that was monitored + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Test Movie", + FileId = 100, + CurrentScore = 250, + CutoffScore = 500, + QualityProfileName = "HD", + IsMonitored = true, + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Movie is now unmonitored + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, Title = "Test Movie", HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, Status = "released", Monitored = false + } + ]); + + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync(new Dictionary { { 100, 250 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — IsMonitored should be updated to false + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.False(entries[0].IsMonitored); + } + + #endregion + + #region Sonarr Sync Tests + + [Fact] + public async Task ExecuteAsync_SyncsSonarrEpisodeScores() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + // Mock quality profiles + _sonarrClient + .Setup(x => x.GetQualityProfilesAsync(sonarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Mock series + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(sonarrInstance)) + .ReturnsAsync([ + new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true } + ]); + + // Mock episodes — one with a file, one without + _sonarrClient + .Setup(x => x.GetEpisodesAsync(sonarrInstance, 10)) + .ReturnsAsync([ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true }, + new SearchableEpisode { Id = 101, SeasonNumber = 1, EpisodeNumber = 2, EpisodeFileId = 0, HasFile = false } + ]); + + // Mock episode files with CF scores + _sonarrClient + .Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10)) + .ReturnsAsync([ + new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false } + ]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — only the episode with a file should have an entry + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + + var entry = entries[0]; + Assert.Equal(sonarrInstance.Id, entry.ArrInstanceId); + Assert.Equal(10, entry.ExternalItemId); + Assert.Equal(100, entry.EpisodeId); + Assert.Equal(300, entry.CurrentScore); + Assert.Equal(500, entry.CutoffScore); + Assert.Equal(InstanceType.Sonarr, entry.ItemType); + Assert.True(entry.IsMonitored); + Assert.Contains("S01E01", entry.Title); + + // Initial history should be created + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + Assert.Equal(300, history[0].Score); + } + + [Fact] + public async Task ExecuteAsync_SonarrSync_SkipsEpisodesWithoutFiles() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + _sonarrClient + .Setup(x => x.GetQualityProfilesAsync(sonarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(sonarrInstance)) + .ReturnsAsync([ + new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true } + ]); + + // All episodes have EpisodeFileId = 0 (no file) + _sonarrClient + .Setup(x => x.GetEpisodesAsync(sonarrInstance, 10)) + .ReturnsAsync([ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false } + ]); + + _sonarrClient + .Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10)) + .ReturnsAsync([]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no entries created + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Empty(entries); + } + + #endregion + + #region Score Unchanged Tests + + [Fact] + public async Task ExecuteAsync_ScoreUnchanged_DoesNotRecordHistory() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing entry with score = 250 (same as what will be returned) + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Test Movie", + FileId = 100, + CurrentScore = 250, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, Title = "Test Movie", HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, Status = "released", Monitored = true + } + ]); + + // Score unchanged: still 250 + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is>(ids => ids.Contains(100)))) + .ReturnsAsync(new Dictionary { { 100, 250 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no history entries (score didn't change) + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Empty(history); + + // Entry should still be updated (LastSyncedAt changes) + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(250, entries[0].CurrentScore); + } + + #endregion + + #region Stale Entry Cleanup Tests + + [Fact] + public async Task ExecuteAsync_CleansUpEntriesForRemovedMovies() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing entry for a movie that no longer exists in library + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 999, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Deleted Movie", + FileId = 999, + CurrentScore = 100, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Library now only has movie 10 (not 999) + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, Title = "Current Movie", HasFile = true, + MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false }, + QualityProfileId = 1, Status = "released", Monitored = true + } + ]); + + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync(new Dictionary { { 100, 250 } }); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — entry for removed movie 999 should be deleted + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(10, entries[0].ExternalItemId); + } + + [Fact] + public async Task ExecuteAsync_PreservesEntryWhenMovieExistsButHasNoFile() + { + // Arrange — simulates an RSS upgrade where the old file was removed + // but the new file hasn't been imported yet + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing entry with score history + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Mario Bros", + FileId = 100, + CurrentScore = 250, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + _fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Mario Bros", + Score = 250, + CutoffScore = 500, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Movie still exists in Radarr but HasFile is false (RSS upgrade in progress) + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, Title = "Mario Bros", HasFile = false, + MovieFile = null, + QualityProfileId = 1, Status = "released", Monitored = true + } + ]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — entry and history should be preserved since the movie still exists + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(10, entries[0].ExternalItemId); + Assert.Equal(250, entries[0].CurrentScore); + + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + Assert.Equal(250, history[0].Score); + } + + [Fact] + public async Task ExecuteAsync_PreservesEntryWhenMovieFileScoreNotReturned() + { + // Arrange — simulates a newly imported file that doesn't have CF scores calculated yet + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + + // Pre-existing entry with history + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Mario Bros", + FileId = 100, + CurrentScore = 250, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + _fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Mario Bros", + Score = 250, + CutoffScore = 500, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _radarrClient + .Setup(x => x.GetQualityProfilesAsync(radarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + // Movie has a new file (different FileId) after RSS upgrade + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync([ + new SearchableMovie + { + Id = 10, Title = "Mario Bros", HasFile = true, + MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, + QualityProfileId = 1, Status = "released", Monitored = true + } + ]); + + // New file returns no score (not yet calculated by Radarr) + _radarrClient + .Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync(new Dictionary()); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — entry and history should be preserved since the movie still exists + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(10, entries[0].ExternalItemId); + Assert.Equal(250, entries[0].CurrentScore); + + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_PreservesEntryWhenEpisodeTemporarilyWithoutFile() + { + // Arrange — simulates a Sonarr episode whose file was replaced via RSS + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.UseCustomFormatScore = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true + }); + + // Pre-existing CF score entry for an episode + _fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E01", + FileId = 500, + CurrentScore = 300, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + _fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E01", + Score = 300, + CutoffScore = 500, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + + _sonarrClient + .Setup(x => x.GetQualityProfilesAsync(sonarrInstance)) + .ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(sonarrInstance)) + .ReturnsAsync([ + new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true } + ]); + + // Episode exists but has no file currently (RSS upgrade in progress) + _sonarrClient + .Setup(x => x.GetEpisodesAsync(sonarrInstance, 10)) + .ReturnsAsync([ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false, Monitored = true } + ]); + + _sonarrClient + .Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10)) + .ReturnsAsync([]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — entry and history should be preserved + var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync(); + Assert.Single(entries); + Assert.Equal(10, entries[0].ExternalItemId); + Assert.Equal(100, entries[0].EpisodeId); + Assert.Equal(300, entries[0].CurrentScore); + + var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync(); + Assert.Single(history); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs new file mode 100644 index 00000000..93882fce --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -0,0 +1,1874 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Data.Models.Arr; +using Cleanuparr.Infrastructure.Hubs; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs; + +[Collection(JobHandlerCollection.Name)] +public class SeekerTests : IDisposable +{ + private readonly JobHandlerFixture _fixture; + private readonly Mock> _logger; + private readonly Mock _radarrClient; + private readonly Mock _sonarrClient; + private readonly Mock _dryRunInterceptor; + private readonly Mock _hostingEnvironment; + private readonly Mock> _hubContext; + + public SeekerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _logger = new Mock>(); + _radarrClient = new Mock(); + _sonarrClient = new Mock(); + _dryRunInterceptor = new Mock(); + _hostingEnvironment = new Mock(); + _hubContext = new Mock>(); + + // Default: hub context setup + var mockClients = new Mock(); + var mockClientProxy = new Mock(); + mockClients.Setup(c => c.All).Returns(mockClientProxy.Object); + _hubContext.Setup(h => h.Clients).Returns(mockClients.Object); + + // Default: development mode (skips jitter) + _hostingEnvironment.Setup(x => x.EnvironmentName).Returns("Development"); + + // Default: dry run disabled + _dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(false); + + // Default: PublishSearchTriggered returns a Guid + _fixture.EventPublisher + .Setup(x => x.PublishSearchTriggered( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Guid.NewGuid()); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + private SeekerJob CreateSut() + { + return new SeekerJob( + _logger.Object, + _fixture.DataContext, + _radarrClient.Object, + _sonarrClient.Object, + _fixture.ArrClientFactory.Object, + _fixture.ArrQueueIterator.Object, + _fixture.EventPublisher.Object, + _dryRunInterceptor.Object, + _hostingEnvironment.Object, + _fixture.TimeProvider, + _hubContext.Object + ); + } + + #region ExecuteAsync Tests + + [Fact] + public async Task ExecuteAsync_WhenSearchDisabled_ReturnsEarly() + { + // Arrange — disable search in the seeded config + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = false; + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no search triggered, no arr client interaction + _fixture.ArrClientFactory.Verify( + x => x.GetClient(It.IsAny(), It.IsAny()), + Times.Never); + _fixture.EventPublisher.Verify( + x => x.PublishSearchTriggered( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WhenProactiveSearchDisabled_SkipsProactiveSearch() + { + // Arrange — search enabled but proactive disabled, no queue items + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = false; + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no arr client interaction (no replacement items, proactive disabled) + _fixture.ArrClientFactory.Verify( + x => x.GetClient(It.IsAny(), It.IsAny()), + Times.Never); + _fixture.EventPublisher.Verify( + x => x.PublishSearchTriggered( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_WhenReplacementItemExists_ProcessesReplacementFirst() + { + // Arrange + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + ItemId = 42, + Title = "Test Movie", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for the replacement item + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + _fixture.EventPublisher.Verify( + x => x.PublishSearchTriggered( + radarrInstance.Name, + 1, + It.Is>(items => items.Contains("Test Movie")), + SeekerSearchType.Replacement, + It.IsAny()), + Times.Once); + + // Replacement item should be removed from the queue + var remaining = await _fixture.DataContext.SearchQueue.CountAsync(); + Assert.Equal(0, remaining); + } + + [Fact] + public async Task ExecuteAsync_WhenDryRunEnabled_DoesNotRemoveFromSearchQueue() + { + // Arrange + _dryRunInterceptor.Setup(x => x.IsDryRunEnabled()).ReturnsAsync(true); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + ItemId = 42, + Title = "Test Movie", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered but item stays in queue + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + var remaining = await _fixture.DataContext.SearchQueue.CountAsync(); + Assert.Equal(1, remaining); + } + + [Fact] + public async Task ExecuteAsync_WhenActiveDownloadLimitReached_SkipsInstance() + { + // Arrange — enable proactive search with a Radarr instance + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + // Add a SeekerInstanceConfig with ActiveDownloadLimit = 2 + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + ActiveDownloadLimit = 2 + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + // Return 2 queue items with SizeLeft > 0 (actively downloading), which meets the limit + QueueRecord[] activeDownloads = + [ + new() { Id = 1, Title = "Download 1", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 10, TrackedDownloadState = "downloading" }, + new() { Id = 2, Title = "Download 2", DownloadId = "hash2", Protocol = "torrent", SizeLeft = 2000, MovieId = 20, TrackedDownloadState = "downloading" } + ]; + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action(activeDownloads)); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no search triggered because active downloads >= limit + mockArrClient.Verify( + x => x.SearchItemsAsync(It.IsAny(), It.IsAny>()), + Times.Never); + + _fixture.EventPublisher.Verify( + x => x.PublishSearchTriggered( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + SeekerSearchType.Proactive, + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ExecuteAsync_Radarr_ExcludesMoviesAlreadyInQueue() + { + // Arrange — proactive search enabled with 3 movies, one already in queue + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + // Movie 2 is already in the download queue + QueueRecord[] queuedRecords = + [ + new() { Id = 1, Title = "Movie 2 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 2, TrackedDownloadState = "downloading" } + ]; + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action(queuedRecords)); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, Tags = [] } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered, but NOT for movie 2 + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + Assert.NotNull(capturedSearchItems); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2); + } + + [Fact] + public async Task ExecuteAsync_Radarr_DoesNotExcludeImportFailedItems() + { + // Arrange — movie in queue with importFailed state should still be searchable + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + // Movie 1 is in queue but with importFailed state — should NOT be excluded + QueueRecord[] queuedRecords = + [ + new() { Id = 1, Title = "Movie 1 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 0, MovieId = 1, TrackedDownloadState = "importFailed" } + ]; + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action(queuedRecords)); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for movie 1 (importFailed does not exclude) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_ExcludesSeasonsAlreadyInQueue() + { + // Arrange — series with 2 seasons, season 1 in queue + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + // Season 1 of series 10 is in the queue + QueueRecord[] queuedRecords = + [ + new() { Id = 1, Title = "Series Episode", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, SeriesId = 10, SeasonNumber = 1, TrackedDownloadState = "downloading" } + ]; + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action(queuedRecords)); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(It.IsAny())) + .ReturnsAsync( + [ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } } + ]); + + // Use dates relative to FakeTimeProvider (defaults to Jan 1, 2000) + var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + _sonarrClient + .Setup(x => x.GetEpisodesAsync(It.IsAny(), 10)) + .ReturnsAsync( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + + SeriesSearchItem? capturedSearchItem = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .Callback>((_, items) => capturedSearchItem = items.OfType().FirstOrDefault()) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — season 2 was searched (season 1 excluded because it's in queue) + mockArrClient.Verify( + x => x.SearchItemsAsync(It.IsAny(), It.IsAny>()), + Times.Once); + + Assert.NotNull(capturedSearchItem); + Assert.Equal(2, capturedSearchItem.Id); // Season 2 + Assert.Equal(10, capturedSearchItem.SeriesId); + } + + [Fact] + public async Task ExecuteAsync_QueueFetchFails_ProceedsWithoutFiltering() + { + // Arrange — queue fetch throws, but search should still proceed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + // Queue fetch fails + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .ThrowsAsync(new HttpRequestException("Connection refused")); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search still proceeded despite queue fetch failure + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + } + + #endregion + + #region Radarr Proactive Search Filters + + [Fact] + public async Task ExecuteAsync_Radarr_MonitoredOnlyTrue_ExcludesUnmonitoredMovies() + { + // Arrange — MonitoredOnly is true by default in seed data + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Monitored Movie", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Unmonitored Movie", Status = "released", Monitored = false, Tags = [] } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — only monitored movie searched + Assert.NotNull(capturedSearchItems); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2); + } + + [Fact] + public async Task ExecuteAsync_Radarr_SkipTags_ExcludesMoviesWithMatchingTags() + { + // Arrange + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + SkipTags = ["no-search"] + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Normal Movie", Status = "released", Monitored = true, Tags = ["movies"] }, + new SearchableMovie { Id = 2, Title = "Skipped Movie", Status = "released", Monitored = true, Tags = ["no-search", "movies"] } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — movie with skip tag excluded + Assert.NotNull(capturedSearchItems); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2); + Assert.Contains(capturedSearchItems, item => item.Id == 1); + } + + [Fact] + public async Task ExecuteAsync_Radarr_UseCutoff_SkipsCutoffMetMovies() + { + // Arrange — enable cutoff filtering: only movies with QualityCutoffNotMet should be searched + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + config.UseCutoff = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Cutoff Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — movie with cutoff met should be excluded; missing + cutoff not met should be eligible + Assert.NotNull(capturedSearchItems); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 2); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CycleComplete_StartsNewCycle() + { + // Arrange — all candidate movies are already in search history for current cycle + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId + }); + + // Add history entries for both movies in the current cycle + // Use dates relative to FakeTimeProvider and far enough back to exceed default MinCycleTimeDays + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered (new cycle started) and the CycleId changed + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId); + } + + #endregion + + #region Round-Robin + + [Fact] + public async Task ExecuteAsync_RoundRobin_SelectsOldestProcessedInstance() + { + // Arrange — two Radarr instances, round-robin should pick the oldest processed one + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.UseRoundRobin = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance1 = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr1:7878"); + var radarrInstance2 = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr2:7878"); + + // Instance 1 was processed recently, instance 2 was processed long ago + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance1.Id, + ArrInstance = radarrInstance1, + Enabled = true, + LastProcessedAt = DateTime.UtcNow + }); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance2.Id, + ArrInstance = radarrInstance2, + Enabled = true, + LastProcessedAt = DateTime.UtcNow.AddHours(-24) + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Return movies for both instances — only instance 2 should be called + _radarrClient + .Setup(x => x.GetAllMoviesAsync(It.Is(a => a.Id == radarrInstance2.Id))) + .ReturnsAsync([new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — instance 2 (oldest) was processed, verified by GetAllMoviesAsync being called for it + _radarrClient.Verify( + x => x.GetAllMoviesAsync(It.Is(a => a.Id == radarrInstance2.Id)), + Times.Once); + _radarrClient.Verify( + x => x.GetAllMoviesAsync(It.Is(a => a.Id == radarrInstance1.Id)), + Times.Never); + } + + #endregion + + #region Replacement Edge Cases + + [Fact] + public async Task ExecuteAsync_ReplacementItem_MissingArrInstance_RemovesFromQueue() + { + // Arrange — replacement item references an instance that no longer exists + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + // Add a valid instance just so we can create the queue item with its ID, then detach it + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var instanceId = radarrInstance.Id; + + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = instanceId, + ArrInstance = radarrInstance, + ItemId = 42, + Title = "Orphaned Movie", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + + // Now remove the arr instance to simulate deletion + _fixture.DataContext.ArrInstances.Remove(radarrInstance); + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — queue item should be cleaned up + var remaining = await _fixture.DataContext.SearchQueue.CountAsync(); + Assert.Equal(0, remaining); + + // No search should have been triggered + _fixture.EventPublisher.Verify( + x => x.PublishSearchTriggered( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + #endregion + + #region MinCycleTimeDays + + [Fact] + public async Task ExecuteAsync_Radarr_CycleComplete_WaitsForMinCycleTime() + { + // Arrange — all items searched but MinCycleTimeDays has not elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 7, + TotalEligibleItems = 2 + }); + + // Cycle started 2 days ago — within the 7-day minimum + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no search triggered, cycle not reset + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Never); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CycleComplete_RestartsAfterMinCycleTimeElapsed() + { + // Arrange — all items searched and MinCycleTimeDays has elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 7, + TotalEligibleItems = 2 + }); + + // Cycle started 10 days ago — beyond the 7-day minimum + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-8), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered, cycle was reset + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CycleComplete_NoCycleHistory_StartsNewCycle() + { + // Arrange — cycle complete but no history (cycleStartedAt is null), should not block + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 30 + }); + + // History uses a DIFFERENT CycleId — current cycle has no history entries + var oldCycleId = Guid.NewGuid(); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = oldCycleId, + LastSearchedAt = DateTime.UtcNow.AddDays(-60), + ItemTitle = "Movie 1" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered (item not in current cycle, so it's selected directly) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_CycleComplete_WaitsForMinCycleTime() + { + // Arrange — all series seasons searched but MinCycleTimeDays not elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 7, + TotalEligibleItems = 1 + }); + + // Series history — season already searched in current cycle (started 2 days ago) + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + ItemType = InstanceType.Sonarr, + SeasonNumber = 1, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Test Series" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(sonarrInstance)) + .ReturnsAsync( + [ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } + ]); + + var pastDate = now.AddDays(-30); + _sonarrClient + .Setup(x => x.GetEpisodesAsync(It.IsAny(), 10)) + .ReturnsAsync( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — no search triggered, cycle not reset + mockArrClient.Verify( + x => x.SearchItemsAsync(sonarrInstance, It.IsAny>()), + Times.Never); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_CycleComplete_RestartsAfterMinCycleTimeElapsed() + { + // Arrange — all series seasons searched and MinCycleTimeDays has elapsed + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 7, + TotalEligibleItems = 1 + }); + + // Series history — season already searched in current cycle (started 10 days ago) + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + ItemType = InstanceType.Sonarr, + SeasonNumber = 1, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-10), + ItemTitle = "Test Series" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(sonarrInstance)) + .ReturnsAsync( + [ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 5 } } + ]); + + var pastDate = now.AddDays(-30); + _sonarrClient + .Setup(x => x.GetEpisodesAsync(It.IsAny(), 10)) + .ReturnsAsync( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, HasFile = false, AirDateUtc = pastDate } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(sonarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered, cycle was reset + mockArrClient.Verify( + x => x.SearchItemsAsync(sonarrInstance, It.IsAny>()), + Times.Once); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + Assert.NotEqual(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_RoundRobin_SkipsInstanceWaitingForMinCycleTime() + { + // Arrange — two Radarr instances: one waiting for MinCycleTimeDays, the other has work to do. + // Round-robin tries instances in order of oldest LastProcessedAt. + // Instance A (oldest) is cycle-complete and waiting — no search triggered, moves to instance B. + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.UseRoundRobin = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + // Instance A: cycle complete, waiting for MinCycleTimeDays (oldest LastProcessedAt — tried first) + var instanceA = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-a:7878"); + var cycleIdA = Guid.NewGuid(); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = instanceA.Id, + ArrInstance = instanceA, + Enabled = true, + CurrentCycleId = cycleIdA, + MinCycleTimeDays = 30, + TotalEligibleItems = 1, + LastProcessedAt = now.AddDays(-5) // Oldest — round-robin tries this first + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = instanceA.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = cycleIdA, + LastSearchedAt = now.AddDays(-2), // Cycle started 2 days ago, MinCycleTimeDays=30 + ItemTitle = "Movie A" + }); + + // Instance B: has work to do (newer LastProcessedAt) + var instanceB = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext, "http://radarr-b:7878"); + var cycleIdB = Guid.NewGuid(); + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = instanceB.Id, + ArrInstance = instanceB, + Enabled = true, + CurrentCycleId = cycleIdB, + MinCycleTimeDays = 5, + TotalEligibleItems = 1, + LastProcessedAt = now.AddDays(-1) + }); + // No history for instance B — it hasn't searched anything yet + + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Instance A: return the movie that was already searched in its cycle + _radarrClient + .Setup(x => x.GetAllMoviesAsync(instanceA)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie A", Status = "released", Monitored = true, Tags = [] } + ]); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(instanceB)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 10, Title = "Movie B", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — Instance A was checked (library fetched) but no search triggered + _radarrClient.Verify( + x => x.GetAllMoviesAsync(instanceA), + Times.Once); + // Instance B was processed and searched + _radarrClient.Verify( + x => x.GetAllMoviesAsync(instanceB), + Times.Once); + // Search was only triggered for instance B, not instance A + mockArrClient.Verify( + x => x.SearchItemsAsync(instanceA, It.IsAny>()), + Times.Never); + mockArrClient.Verify( + x => x.SearchItemsAsync(instanceB, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Radarr_NewItemAdded_SearchedDespiteCycleComplete() + { + // Arrange — cycle was complete (2 items searched), but a new item was added to the library. + // The new item should be searched immediately without waiting for MinCycleTimeDays. + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 30, + TotalEligibleItems = 2 // Stale value from previous run + }); + + // History: 2 items searched in current cycle + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Library now has 3 items — the 3rd was newly added + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for the new item (cycle is NOT considered complete) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + // Cycle ID should NOT have changed (cycle is not complete — there's still a new item) + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); + } + + [Fact] + public async Task ExecuteAsync_Radarr_ItemSwapped_SearchesNewItem() + { + // Arrange — cycle was complete (2 items searched), but one item was removed and a new one added. + // Total count is the same, but the library has changed. The new item should be searched. + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.MonitoredOnly = false; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + CurrentCycleId = currentCycleId, + MinCycleTimeDays = 30, + TotalEligibleItems = 2 // Stale value from previous run + }); + + // History: items 1 and 2 were searched + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-2), + ItemTitle = "Movie 1" + }); + _fixture.DataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-1), + ItemTitle = "Movie 2 (Removed)" + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns(Task.CompletedTask); + + // Library: item 2 was removed, item 3 was added (same total count of 2) + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3 (New)", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — search was triggered for the new item + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + // Cycle ID should NOT have changed (the new item hasn't been searched yet) + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + Assert.Equal(currentCycleId, instanceConfig.CurrentCycleId); + } + + #endregion + + #region Post-Release Grace Period Tests + + [Fact] + public async Task ExecuteAsync_Radarr_GracePeriod_ExcludesRecentlyReleasedMovies() + { + // Arrange — grace period of 6 hours, one movie released 2 hours ago (within grace), one released 10 hours ago (past grace) + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 6; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action([])); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Recent Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-2) }, + new SearchableMovie { Id = 2, Title = "Old Movie", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10) } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — only movie 2 (past grace) should be searched + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + Assert.NotNull(capturedSearchItems); + Assert.Single(capturedSearchItems); + Assert.Contains(capturedSearchItems, item => item.Id == 2); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 1); + } + + [Fact] + public async Task ExecuteAsync_Radarr_GracePeriodZero_DoesNotFilterMovies() + { + // Arrange — grace period of 0 (disabled) + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 0; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action([])); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "Just Released", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddMinutes(-5) } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — movie should be searched (grace period disabled) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Radarr_GracePeriod_NoReleaseDates_TreatsAsReleased() + { + // Arrange — movie with no release date info should not be filtered by grace period + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 6; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action([])); + + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + new SearchableMovie { Id = 1, Title = "No Dates Movie", Status = "released", Monitored = true, Tags = [] } + ]); + + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — movie should be searched (no dates = treated as released) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_GracePeriod_ExcludesRecentlyAiredEpisodes() + { + // Arrange — grace period of 6 hours, one episode aired 2 hours ago (within grace), one aired 10 hours ago (past grace) + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 6; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action([])); + + _sonarrClient + .Setup(x => x.GetAllSeriesAsync(It.IsAny())) + .ReturnsAsync( + [ + new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 8 } } + ]); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + _sonarrClient + .Setup(x => x.GetEpisodesAsync(It.IsAny(), 10)) + .ReturnsAsync( + [ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-2), HasFile = false }, + new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = now.AddHours(-10), HasFile = false } + ]); + + SeriesSearchItem? capturedSearchItem = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(It.IsAny(), It.IsAny>())) + .Callback>((_, items) => capturedSearchItem = items.OfType().FirstOrDefault()) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Sonarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — only season 2 should be searched (season 1's episode is within grace period) + mockArrClient.Verify( + x => x.SearchItemsAsync(It.IsAny(), It.IsAny>()), + Times.Once); + + Assert.NotNull(capturedSearchItem); + Assert.Equal(2, capturedSearchItem.Id); // Season 2 + } + + [Fact] + public async Task ExecuteAsync_Radarr_GracePeriod_UsesReleaseDateFallbackOrder() + { + // Arrange — movie with only PhysicalRelease (no DigitalRelease), within grace period + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + config.PostReleaseGraceHours = 6; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = new Mock(); + + _fixture.ArrQueueIterator + .Setup(x => x.Iterate(mockArrClient.Object, It.IsAny(), It.IsAny, Task>>())) + .Returns, Task>>((_, _, action) => action([])); + + var now = _fixture.TimeProvider.GetUtcNow().UtcDateTime; + _radarrClient + .Setup(x => x.GetAllMoviesAsync(radarrInstance)) + .ReturnsAsync( + [ + // DigitalRelease is null, PhysicalRelease is 2h ago (within grace) + new SearchableMovie { Id = 1, Title = "Physical Only", Status = "released", Monitored = true, Tags = [], PhysicalRelease = now.AddHours(-2) }, + // DigitalRelease is 10h ago (past grace), PhysicalRelease is 2h ago — DigitalRelease takes precedence + new SearchableMovie { Id = 2, Title = "Digital First", Status = "released", Monitored = true, Tags = [], DigitalRelease = now.AddHours(-10), PhysicalRelease = now.AddHours(-2) } + ]); + + HashSet? capturedSearchItems = null; + mockArrClient + .Setup(x => x.SearchItemsAsync(radarrInstance, It.IsAny>())) + .Callback>((_, items) => capturedSearchItems = items) + .ReturnsAsync([100L]); + + _fixture.ArrClientFactory + .Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny())) + .Returns(mockArrClient.Object); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — movie 1 excluded (PhysicalRelease within grace), movie 2 included (DigitalRelease past grace) + mockArrClient.Verify( + x => x.SearchItemsAsync(radarrInstance, It.IsAny>()), + Times.Once); + + Assert.NotNull(capturedSearchItems); + Assert.Single(capturedSearchItems); + Assert.Contains(capturedSearchItems, item => item.Id == 2); + Assert.DoesNotContain(capturedSearchItems, item => item.Id == 1); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs index f43ba633..b040269b 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/TestDataContextFactory.cs @@ -6,6 +6,7 @@ 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; @@ -94,6 +95,14 @@ public static class TestDataContextFactory UnlinkedCategories = [] }); + // Seeker config + context.SeekerConfigs.Add(new SeekerConfig + { + Id = Guid.NewGuid(), + SearchEnabled = true, + ProactiveSearchEnabled = false + }); + context.SaveChanges(); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs new file mode 100644 index 00000000..d2a23c8c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Seeker/ItemSelectorTests.cs @@ -0,0 +1,420 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Seeker; +using Cleanuparr.Infrastructure.Features.Seeker.Selectors; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Seeker; + +public sealed class ItemSelectorTests +{ + private static readonly List<(long Id, DateTime? Added, DateTime? LastSearched)> SampleCandidates = + [ + (1, new DateTime(2024, 1, 1), new DateTime(2024, 6, 1)), + (2, new DateTime(2024, 3, 1), new DateTime(2024, 5, 1)), + (3, new DateTime(2024, 5, 1), null), + (4, new DateTime(2024, 2, 1), new DateTime(2024, 7, 1)), + (5, null, new DateTime(2024, 4, 1)), + ]; + + #region ItemSelectorFactory Tests + + [Theory] + [InlineData(SelectionStrategy.OldestSearchFirst, typeof(OldestSearchFirstSelector))] + [InlineData(SelectionStrategy.OldestSearchWeighted, typeof(OldestSearchWeightedSelector))] + [InlineData(SelectionStrategy.NewestFirst, typeof(NewestFirstSelector))] + [InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))] + [InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))] + [InlineData(SelectionStrategy.Random, typeof(RandomSelector))] + public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType) + { + var selector = ItemSelectorFactory.Create(strategy); + + Assert.IsType(expectedType, selector); + } + + [Fact] + public void Factory_Create_InvalidStrategy_ThrowsArgumentOutOfRangeException() + { + Assert.Throws(() => ItemSelectorFactory.Create((SelectionStrategy)999)); + } + + #endregion + + #region NewestFirstSelector Tests + + [Fact] + public void NewestFirst_Select_OrdersByAddedDescending() + { + var selector = new NewestFirstSelector(); + + var result = selector.Select(SampleCandidates, 3); + + // Newest added: 3 (May), 2 (Mar), 4 (Feb) + Assert.Equal([3, 2, 4], result); + } + + [Fact] + public void NewestFirst_Select_NullAddedDates_TreatedAsOldest() + { + var selector = new NewestFirstSelector(); + + // Select all — item 5 (null Added) should be last + var result = selector.Select(SampleCandidates, 5); + + Assert.Equal(5, result.Last()); + } + + [Fact] + public void NewestFirst_Select_ReturnsRequestedCount() + { + var selector = new NewestFirstSelector(); + + var result = selector.Select(SampleCandidates, 2); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void NewestFirst_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new NewestFirstSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + [Fact] + public void NewestFirst_Select_CountExceedsCandidates_ReturnsAll() + { + var selector = new NewestFirstSelector(); + + var result = selector.Select(SampleCandidates, 100); + + Assert.Equal(5, result.Count); + } + + #endregion + + #region OldestSearchFirstSelector Tests + + [Fact] + public void OldestSearchFirst_Select_OrdersByLastSearchedAscending() + { + var selector = new OldestSearchFirstSelector(); + + var result = selector.Select(SampleCandidates, 3); + + // Never searched first (null → MinValue), then oldest: 3 (null), 5 (Apr), 2 (May) + Assert.Equal([3, 5, 2], result); + } + + [Fact] + public void OldestSearchFirst_Select_NullLastSearched_PrioritizedFirst() + { + var selector = new OldestSearchFirstSelector(); + + var result = selector.Select(SampleCandidates, 1); + + // Item 3 has LastSearched = null, should be first + Assert.Equal(3, result[0]); + } + + [Fact] + public void OldestSearchFirst_Select_ReturnsRequestedCount() + { + var selector = new OldestSearchFirstSelector(); + + var result = selector.Select(SampleCandidates, 2); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void OldestSearchFirst_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new OldestSearchFirstSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + #endregion + + #region RandomSelector Tests + + [Fact] + public void Random_Select_ReturnsRequestedCount() + { + var selector = new RandomSelector(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void Random_Select_CountExceedsCandidates_ReturnsAll() + { + var selector = new RandomSelector(); + + var result = selector.Select(SampleCandidates, 100); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void Random_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new RandomSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + [Fact] + public void Random_Select_NoDuplicateIds() + { + var selector = new RandomSelector(); + + var result = selector.Select(SampleCandidates, 5); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void Random_Select_ResultsAreSubsetOfInput() + { + var selector = new RandomSelector(); + var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.All(result, id => Assert.Contains(id, inputIds)); + } + + #endregion + + #region NewestWeightedSelector Tests + + [Fact] + public void NewestWeighted_Select_ReturnsRequestedCount() + { + var selector = new NewestWeightedSelector(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void NewestWeighted_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new NewestWeightedSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + [Fact] + public void NewestWeighted_Select_CountExceedsCandidates_ReturnsAll() + { + var selector = new NewestWeightedSelector(); + + var result = selector.Select(SampleCandidates, 100); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void NewestWeighted_Select_NoDuplicateIds() + { + var selector = new NewestWeightedSelector(); + + var result = selector.Select(SampleCandidates, 5); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate() + { + var selector = new NewestWeightedSelector(); + List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)]; + + var result = selector.Select(single, 1); + + Assert.Single(result); + Assert.Equal(42, result[0]); + } + + #endregion + + #region OldestSearchWeightedSelector Tests + + [Fact] + public void OldestSearchWeighted_Select_ReturnsRequestedCount() + { + var selector = new OldestSearchWeightedSelector(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void OldestSearchWeighted_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new OldestSearchWeightedSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + [Fact] + public void OldestSearchWeighted_Select_CountExceedsCandidates_ReturnsAll() + { + var selector = new OldestSearchWeightedSelector(); + + var result = selector.Select(SampleCandidates, 100); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void OldestSearchWeighted_Select_NoDuplicateIds() + { + var selector = new OldestSearchWeightedSelector(); + + var result = selector.Select(SampleCandidates, 5); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate() + { + var selector = new OldestSearchWeightedSelector(); + List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)]; + + var result = selector.Select(single, 1); + + Assert.Single(result); + Assert.Equal(42, result[0]); + } + + #endregion + + #region BalancedWeightedSelector Tests + + [Fact] + public void BalancedWeighted_Select_ReturnsRequestedCount() + { + var selector = new BalancedWeightedSelector(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void BalancedWeighted_Select_EmptyInput_ReturnsEmptyList() + { + var selector = new BalancedWeightedSelector(); + + var result = selector.Select([], 5); + + Assert.Empty(result); + } + + [Fact] + public void BalancedWeighted_Select_CountExceedsCandidates_ReturnsAll() + { + var selector = new BalancedWeightedSelector(); + + var result = selector.Select(SampleCandidates, 100); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void BalancedWeighted_Select_NoDuplicateIds() + { + var selector = new BalancedWeightedSelector(); + + var result = selector.Select(SampleCandidates, 5); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate() + { + var selector = new BalancedWeightedSelector(); + List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)]; + + var result = selector.Select(single, 1); + + Assert.Single(result); + Assert.Equal(42, result[0]); + } + + [Fact] + public void BalancedWeighted_Select_ResultsAreSubsetOfInput() + { + var selector = new BalancedWeightedSelector(); + var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet(); + + var result = selector.Select(SampleCandidates, 3); + + Assert.All(result, id => Assert.Contains(id, inputIds)); + } + + #endregion + + #region WeightedRandomByRank Tests + + [Fact] + public void WeightedRandomByRank_ReturnsRequestedCount() + { + var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList(); + + var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 3); + + Assert.Equal(3, result.Count); + } + + [Fact] + public void WeightedRandomByRank_CountExceedsCandidates_ReturnsAll() + { + var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList(); + + var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 100); + + Assert.Equal(5, result.Count); + } + + [Fact] + public void WeightedRandomByRank_NoDuplicateIds() + { + var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList(); + + var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 5); + + Assert.Equal(result.Count, result.Distinct().Count()); + } + + [Fact] + public void WeightedRandomByRank_EmptyInput_ReturnsEmptyList() + { + var result = OldestSearchWeightedSelector.WeightedRandomByRank([], 5); + + Assert.Empty(result); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index 8b332c35..eade5573 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -227,6 +227,86 @@ public class EventPublisher : IEventPublisher ); } + /// + /// Publishes a search triggered event with context data and notifications. + /// Returns the event ID so the SeekerCommandMonitor can update it on completion. + /// + public async Task PublishSearchTriggered(string instanceName, int itemCount, IEnumerable items, SeekerSearchType searchType, Guid? cycleId = null) + { + var itemList = items as string[] ?? items.ToArray(); + var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : ""); + + AppEvent eventEntity = new() + { + EventType = EventType.SearchTriggered, + Message = $"Searched {itemCount} items on {instanceName}: {itemsDisplay}", + Severity = EventSeverity.Information, + Data = JsonSerializer.Serialize( + new { InstanceName = instanceName, ItemCount = itemCount, Items = itemList, SearchType = searchType.ToString(), CycleId = cycleId }, + new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }), + SearchStatus = SearchCommandStatus.Pending, + JobRunId = ContextProvider.TryGetJobRunId(), + InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null, + InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(), + DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null, + DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string, + CycleId = cycleId, + }; + + eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + await SaveEventToDatabase(eventEntity); + await NotifyClientsAsync(eventEntity); + await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList); + + return eventEntity.Id; + } + + /// + /// Updates an existing search event with completion status and optional result data + /// + public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null) + { + var existingEvent = await _context.Events.FindAsync(eventId); + if (existingEvent is null) + { + _logger.LogWarning("Could not find search event {EventId} to update completion status", eventId); + return; + } + + existingEvent.SearchStatus = status; + existingEvent.CompletedAt = DateTime.UtcNow; + + if (resultData is not null) + { + // Merge result data into existing Data JSON + var existingData = existingEvent.Data is not null + ? JsonSerializer.Deserialize>(existingEvent.Data) + : new Dictionary(); + + var resultJson = JsonSerializer.Serialize(resultData, new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }); + var resultDict = JsonSerializer.Deserialize>(resultJson); + + if (existingData is not null && resultDict is not null) + { + foreach (var kvp in resultDict) + { + existingData[kvp.Key] = kvp.Value; + } + + existingEvent.Data = JsonSerializer.Serialize(existingData, new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }); + } + } + + await _context.SaveChangesAsync(); + await NotifyClientsAsync(existingEvent); + } + /// /// Publishes an event alerting that search was not triggered for an item /// diff --git a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs index 77b09b70..a691aae8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs @@ -19,4 +19,8 @@ public interface IEventPublisher Task PublishRecurringItem(string hash, string itemName, int strikeCount); Task PublishSearchNotTriggered(string hash, string itemName); + + Task PublishSearchTriggered(string instanceName, int itemCount, IEnumerable items, SeekerSearchType searchType, Guid? cycleId = null); + + Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index 4445793f..960c67ca 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -1,3 +1,4 @@ +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; @@ -41,8 +42,8 @@ public abstract class ArrClient : IArrClient using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - - using HttpResponseMessage response = await _httpClient.SendAsync(request); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); try { @@ -53,18 +54,46 @@ public abstract class ArrClient : IArrClient _logger.LogError("queue list failed | {uri}", uriBuilder.Uri); throw; } - - string responseBody = await response.Content.ReadAsStringAsync(); - QueueListResponse? queueResponse = JsonConvert.DeserializeObject(responseBody); + + QueueListResponse? queueResponse = await DeserializeStreamAsync(response); if (queueResponse is null) { - throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}"); + throw new Exception($"unrecognized queue list response | {uriBuilder.Uri}"); } return queueResponse; } + public async Task GetActiveDownloadCountAsync(ArrInstance arrInstance) + { + int count = 0; + int page = 1; + int processed = 0; + + while (true) + { + QueueListResponse response = await GetQueueItemsAsync(arrInstance, page); + + if (response.Records.Count == 0) + { + break; + } + + count += response.Records.Count(r => r.SizeLeft > 0); + processed += response.Records.Count; + + if (processed >= response.TotalRecords) + { + break; + } + + page++; + } + + return count; + } + public virtual async Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes) { var queueCleanerConfig = ContextProvider.Get(); @@ -166,7 +195,7 @@ public abstract class ArrClient : IArrClient } } - public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items); + public abstract Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items); public bool IsRecordValid(QueueRecord record) { @@ -197,6 +226,23 @@ public abstract class ArrClient : IArrClient _logger.LogDebug("Connection test successful for {url}", arrInstance.Url); } + /// + public async Task GetCommandStatusAsync(ArrInstance arrInstance, long commandId) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command/{commandId}"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var result = await DeserializeStreamAsync(response); + + return result ?? new ArrCommandStatus(commandId, "unknown", null); + } + protected abstract string GetSystemStatusUrlPath(); protected abstract string GetQueueUrlPath(); @@ -221,6 +267,26 @@ public abstract class ArrClient : IArrClient return response; } + protected static async Task DeserializeStreamAsync(HttpResponseMessage response) + { + using Stream stream = await response.Content.ReadAsStreamAsync(); + using StreamReader sr = new(stream); + using JsonTextReader reader = new(sr); + return JsonSerializer.CreateDefault().Deserialize(reader); + } + + protected static async Task ReadCommandIdAsync(HttpResponseMessage response) + { + CommandIdResponse? result = await DeserializeStreamAsync(response); + return result?.Id; + } + + private sealed class CommandIdResponse + { + [JsonProperty("id")] + public long? Id { get; init; } + } + /// /// Determines whether the failed import record should be skipped /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index ed856a46..8d377718 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -1,3 +1,4 @@ +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.Arr; @@ -12,9 +13,17 @@ public interface IArrClient Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes); Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason); - - Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items); - + + /// + /// Triggers a search for the specified items and returns the arr command IDs + /// + Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items); + + /// + /// Gets the status of an arr command by its ID + /// + Task GetCommandStatusAsync(ArrInstance arrInstance, long commandId); + bool IsRecordValid(QueueRecord record); /// @@ -30,4 +39,10 @@ public interface IArrClient /// The instance to test connection to /// Task that completes when the connection test is done Task HealthCheckAsync(ArrInstance arrInstance); + + /// + /// Returns the number of items actively downloading (SizeLeft > 0) across all queue pages. + /// Items that are completed, import-blocked, or otherwise finished are not counted. + /// + Task GetActiveDownloadCountAsync(ArrInstance arrInstance); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs index bfd1d020..2d1801dc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IRadarrClient.cs @@ -1,5 +1,22 @@ -namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; public interface IRadarrClient : IArrClient { + /// + /// Fetches all movies from a Radarr instance + /// + Task> GetAllMoviesAsync(ArrInstance arrInstance); + + /// + /// Fetches quality profiles from a Radarr instance + /// + Task> GetQualityProfilesAsync(ArrInstance arrInstance); + + /// + /// Fetches custom format scores for movie files in batches + /// + Task> GetMovieFileScoresAsync(ArrInstance arrInstance, List movieFileIds); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs index 3f9c4618..546a6788 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/ISonarrClient.cs @@ -1,5 +1,32 @@ -namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; public interface ISonarrClient : IArrClient { + /// + /// Fetches all series from a Sonarr instance + /// + Task> GetAllSeriesAsync(ArrInstance arrInstance); + + /// + /// Fetches all episodes for a specific series from a Sonarr instance + /// + Task> GetEpisodesAsync(ArrInstance arrInstance, long seriesId); + + /// + /// Fetches quality profiles from a Sonarr instance + /// + Task> GetQualityProfilesAsync(ArrInstance arrInstance); + + /// + /// Fetches episode file metadata for a specific series, including quality cutoff status + /// + Task> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId); + + /// + /// Fetches custom format scores for episode files in batches + /// + Task> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List episodeFileIds); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index ee3ed48c..280d3513 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -50,11 +50,11 @@ public class LidarrClient : ArrClient, ILidarrClient return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } UriBuilder uriBuilder = new(arrInstance.Url); @@ -85,6 +85,8 @@ public class LidarrClient : ArrClient, ILidarrClient throw; } } + + return []; } public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0; @@ -137,15 +139,14 @@ public class LidarrClient : ArrClient, ILidarrClient UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album"; uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}")); - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using var response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject>(responseBody); + return await DeserializeStreamAsync>(response); } private List GetSearchCommands(HashSet items) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index 8f4d807b..7c8711d2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -1,4 +1,5 @@ using System.Text; +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Entities.Radarr; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; @@ -50,24 +51,24 @@ public class RadarrClient : ArrClient, IRadarrClient return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } List ids = items.Select(item => item.Id).ToList(); - + UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command"; - + RadarrCommand command = new() { Name = "MoviesSearch", MovieIds = ids, }; - + using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri); request.Content = new StringContent( JsonConvert.SerializeObject(command), @@ -81,9 +82,18 @@ public class RadarrClient : ArrClient, IRadarrClient try { HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); - response?.Dispose(); - + + if (response is null) + { + return []; + } + + long? commandId = await ReadCommandIdAsync(response); + response.Dispose(); + _logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext)); + + return commandId.HasValue ? [commandId.Value] : []; } catch { @@ -130,18 +140,77 @@ public class RadarrClient : ArrClient, IRadarrClient return null; } + public async Task> GetAllMoviesAsync(ArrInstance arrInstance) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + using Stream stream = await response.Content.ReadAsStreamAsync(); + using StreamReader sr = new(stream); + using JsonTextReader reader = new(sr); + JsonSerializer serializer = JsonSerializer.CreateDefault(); + return serializer.Deserialize>(reader) ?? []; + } + + public async Task> GetQualityProfilesAsync(ArrInstance arrInstance) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + return await DeserializeStreamAsync>(response) ?? []; + } + + public async Task> GetMovieFileScoresAsync(ArrInstance arrInstance, List movieFileIds) + { + Dictionary scores = new(); + + // Batch in chunks of 100 to avoid 414 URI Too Long + foreach (long[] batch in movieFileIds.Chunk(100)) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/moviefile"; + uriBuilder.Query = string.Join('&', batch.Select(id => $"movieFileIds={id}")); + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + List files = await DeserializeStreamAsync>(response) ?? []; + + foreach (MediaFileScore file in files) + { + scores[file.Id] = file.CustomFormatScore; + } + } + + return scores; + } + private async Task GetMovie(ArrInstance arrInstance, long movieId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}"; - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(responseBody); + + return await DeserializeStreamAsync(response); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index 530ea050..f86cb442 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -50,11 +50,11 @@ public class ReadarrClient : ArrClient, IReadarrClient return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } List ids = items.Select(item => item.Id).ToList(); @@ -90,6 +90,8 @@ public class ReadarrClient : ArrClient, IReadarrClient _logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext)); throw; } + + return []; } public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0; @@ -134,14 +136,13 @@ public class ReadarrClient : ArrClient, IReadarrClient { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}"; - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(responseBody); + + return await DeserializeStreamAsync(response); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index df15a925..9a74b03f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -53,16 +53,18 @@ public class SonarrClient : ArrClient, ISonarrClient return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } + List commandIds = []; + UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command"; - + foreach (SonarrCommand command in GetSearchCommands(items.Cast().ToHashSet())) { using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri); @@ -78,8 +80,18 @@ public class SonarrClient : ArrClient, ISonarrClient try { HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); - response?.Dispose(); - + + if (response is not null) + { + long? commandId = await ReadCommandIdAsync(response); + response.Dispose(); + + if (commandId.HasValue) + { + commandIds.Add(commandId.Value); + } + } + _logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext)); } catch @@ -88,6 +100,8 @@ public class SonarrClient : ArrClient, ISonarrClient throw; } } + + return commandIds; } public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0; @@ -195,35 +209,123 @@ public class SonarrClient : ArrClient, ISonarrClient return null; } + public async Task> GetAllSeriesAsync(ArrInstance arrInstance) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + using Stream stream = await response.Content.ReadAsStreamAsync(); + using StreamReader sr = new(stream); + using JsonTextReader reader = new(sr); + JsonSerializer serializer = JsonSerializer.CreateDefault(); + return serializer.Deserialize>(reader) ?? []; + } + + public async Task> GetEpisodesAsync(ArrInstance arrInstance, long seriesId) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode"; + uriBuilder.Query = $"seriesId={seriesId}"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + return await DeserializeStreamAsync>(response) ?? []; + } + + public async Task> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile"; + uriBuilder.Query = $"seriesId={seriesId}"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + return await DeserializeStreamAsync>(response) ?? []; + } + + public async Task> GetQualityProfilesAsync(ArrInstance arrInstance) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + return await DeserializeStreamAsync>(response) ?? []; + } + + public async Task> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List episodeFileIds) + { + Dictionary scores = new(); + + // Batch in chunks of 100 to avoid 414 URI Too Long + foreach (long[] batch in episodeFileIds.Chunk(100)) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile"; + uriBuilder.Query = string.Join('&', batch.Select(id => $"episodeFileIds={id}")); + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + List files = await DeserializeStreamAsync>(response) ?? []; + + foreach (MediaFileScore file in files) + { + scores[file.Id] = file.CustomFormatScore; + } + } + + return scores; + } + private async Task?> GetEpisodesAsync(ArrInstance arrInstance, List episodeIds) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode"; uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}")); - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject>(responseBody); + + return await DeserializeStreamAsync>(response); } private async Task GetSeriesAsync(ArrInstance arrInstance, long seriesId) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}"; - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(responseBody); + + return await DeserializeStreamAsync(response); } private List GetSearchCommands(HashSet items) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs index c2b14dc0..07db1f8b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs @@ -53,11 +53,11 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } UriBuilder uriBuilder = new(arrInstance.Url); @@ -88,6 +88,8 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client throw; } } + + return []; } public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0; @@ -204,10 +206,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - HttpResponseMessage response = await SendRequestAsync(request); - string responseContent = await response.Content.ReadAsStringAsync(); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); - return JsonConvert.DeserializeObject>(responseContent); + return await DeserializeStreamAsync>(response); } private async Task GetSeriesAsync(ArrInstance arrInstance, long seriesId) @@ -218,10 +220,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - HttpResponseMessage response = await SendRequestAsync(request); - string responseContent = await response.Content.ReadAsStringAsync(); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); - return JsonConvert.DeserializeObject(responseContent); + return await DeserializeStreamAsync(response); } private List GetSearchCommands(HashSet items) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs index 503d98f7..58f868b8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs @@ -51,11 +51,11 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client return query; } - public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) { - return; + return []; } List ids = items.Select(item => item.Id).ToList(); @@ -91,6 +91,8 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client _logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext)); throw; } + + return []; } public override bool HasContentId(QueueRecord record) => record.MovieId is not 0; @@ -135,14 +137,13 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}"; - + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); SetApiKey(request, arrInstance.ApiKey); - using HttpResponseMessage response = await _httpClient.SendAsync(request); + using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - - string responseBody = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(responseBody); + + return await DeserializeStreamAsync(response); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs index bb3a1467..906c49b3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/BlacklistSync/BlacklistSynchronizer.cs @@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler _dryRunInterceptor = dryRunInterceptor; } - public async Task ExecuteAsync() + public async Task ExecuteAsync(CancellationToken cancellationToken = default) { BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs .AsNoTracking() diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Consumers/DownloadHunterConsumer.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Consumers/DownloadHunterConsumer.cs deleted file mode 100644 index d859f249..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Consumers/DownloadHunterConsumer.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; -using Data.Models.Arr; -using MassTransit; -using Microsoft.Extensions.Logging; - -namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers; - -public class DownloadHunterConsumer : IConsumer> - where T : SearchItem -{ - private readonly ILogger> _logger; - private readonly IDownloadHunter _downloadHunter; - - public DownloadHunterConsumer(ILogger> logger, IDownloadHunter downloadHunter) - { - _logger = logger; - _downloadHunter = downloadHunter; - } - - public async Task Consume(ConsumeContext> context) - { - try - { - await _downloadHunter.HuntDownloadsAsync(context.Message); - } - catch (Exception exception) - { - _logger.LogError(exception, - "failed to search for replacement | {title} | {url}", - context.Message.Record.Title, - context.Message.Instance.Url - ); - } - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs deleted file mode 100644 index 576b422d..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/DownloadHunter.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Cleanuparr.Infrastructure.Features.Arr; -using Cleanuparr.Infrastructure.Features.Arr.Interfaces; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; -using Cleanuparr.Persistence; -using Cleanuparr.Shared.Helpers; -using Data.Models.Arr; -using Microsoft.EntityFrameworkCore; - -namespace Cleanuparr.Infrastructure.Features.DownloadHunter; - -public sealed class DownloadHunter : IDownloadHunter -{ - private readonly DataContext _dataContext; - private readonly IArrClientFactory _arrClientFactory; - private readonly TimeProvider _timeProvider; - - public DownloadHunter( - DataContext dataContext, - IArrClientFactory arrClientFactory, - TimeProvider timeProvider - ) - { - _dataContext = dataContext; - _arrClientFactory = arrClientFactory; - _timeProvider = timeProvider; - } - - public async Task HuntDownloadsAsync(DownloadHuntRequest request) - where T : SearchItem - { - var generalConfig = await _dataContext.GeneralConfigs - .AsNoTracking() - .FirstAsync(); - - if (!generalConfig.SearchEnabled) - { - return; - } - - var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version); - await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]); - - // Prevent manual db edits - if (generalConfig.SearchDelay < Constants.MinSearchDelaySeconds) - { - generalConfig.SearchDelay = Constants.DefaultSearchDelaySeconds; - } - - // Prevent tracker spamming - await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay), _timeProvider); - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Interfaces/IDownloadHunter.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Interfaces/IDownloadHunter.cs deleted file mode 100644 index 7301e4ca..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Interfaces/IDownloadHunter.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; -using Data.Models.Arr; - -namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces; - -public interface IDownloadHunter -{ - Task HuntDownloadsAsync(DownloadHuntRequest request) where T : SearchItem; -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Models/DownloadHuntRequest.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Models/DownloadHuntRequest.cs deleted file mode 100644 index c3e5ce6f..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadHunter/Models/DownloadHuntRequest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Cleanuparr.Domain.Entities.Arr.Queue; -using Cleanuparr.Domain.Enums; -using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; - -namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Models; - -public sealed record DownloadHuntRequest - where T : SearchItem -{ - public required InstanceType InstanceType { get; init; } - - public required ArrInstance Instance { get; init; } - - public required T SearchItem { get; init; } - - public required QueueRecord Record { get; init; } - - public required Guid JobRunId { get; init; } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index 0d90d825..18e491dd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -1,20 +1,18 @@ using System.Net; +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Events.Interfaces; -using Cleanuparr.Infrastructure.Features.Arr; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Persistence; -using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; using Data.Models.Arr; -using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -24,27 +22,27 @@ namespace Cleanuparr.Infrastructure.Features.DownloadRemover; public sealed class QueueItemRemover : IQueueItemRemover { private readonly ILogger _logger; - private readonly IBus _messageBus; private readonly IMemoryCache _cache; private readonly IArrClientFactory _arrClientFactory; private readonly IEventPublisher _eventPublisher; private readonly EventsContext _eventsContext; + private readonly DataContext _dataContext; public QueueItemRemover( ILogger logger, - IBus messageBus, IMemoryCache cache, IArrClientFactory arrClientFactory, IEventPublisher eventPublisher, - EventsContext eventsContext + EventsContext eventsContext, + DataContext dataContext ) { _logger = logger; - _messageBus = messageBus; _cache = cache; _arrClientFactory = arrClientFactory; _eventPublisher = eventPublisher; _eventsContext = eventsContext; + _dataContext = dataContext; } public async Task RemoveQueueItemAsync(QueueItemRemoveRequest request) @@ -90,14 +88,26 @@ public sealed class QueueItemRemover : IQueueItemRemover return; } - await _messageBus.Publish(new DownloadHuntRequest + SeekerConfig seekerConfig = await _dataContext.SeekerConfigs + .AsNoTracking() + .FirstAsync(); + + if (!seekerConfig.SearchEnabled) { - InstanceType = request.InstanceType, - Instance = request.Instance, - SearchItem = request.SearchItem, - Record = request.Record, - JobRunId = request.JobRunId + _logger.LogDebug("Search not triggered | {name}", request.Record.Title); + return; + } + + _dataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = request.Instance.Id, + ItemId = request.SearchItem.Id, + SeriesId = (request.SearchItem as SeriesSearchItem)?.SeriesId, + SearchType = (request.SearchItem as SeriesSearchItem)?.SearchType.ToString(), + Title = request.Record.Title, }); + + await _dataContext.SaveChangesAsync(); } catch (HttpRequestException exception) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs new file mode 100644 index 00000000..095e2e99 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs @@ -0,0 +1,456 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Cleanuparr.Infrastructure.Hubs; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.Jobs; + +/// +/// Periodically syncs custom format scores from Radarr/Sonarr instances. +/// Tracks score changes over time for dashboard display and Seeker filtering. +/// +public sealed class CustomFormatScoreSyncer : IHandler +{ + private const int ChunkSize = 200; + private static readonly TimeSpan HistoryRetention = TimeSpan.FromDays(120); + + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IRadarrClient _radarrClient; + private readonly ISonarrClient _sonarrClient; + private readonly TimeProvider _timeProvider; + private readonly IHubContext _hubContext; + + public CustomFormatScoreSyncer( + ILogger logger, + DataContext dataContext, + IRadarrClient radarrClient, + ISonarrClient sonarrClient, + TimeProvider timeProvider, + IHubContext hubContext) + { + _logger = logger; + _dataContext = dataContext; + _radarrClient = radarrClient; + _sonarrClient = sonarrClient; + _timeProvider = timeProvider; + _hubContext = hubContext; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + SeekerConfig config = await _dataContext.SeekerConfigs + .AsNoTracking() + .FirstAsync(); + + if (!config.UseCustomFormatScore) + { + _logger.LogTrace("Custom format score tracking is disabled"); + return; + } + + List instanceConfigs = await _dataContext.SeekerInstanceConfigs + .Include(s => s.ArrInstance) + .ThenInclude(a => a.ArrConfig) + .Where(s => s.Enabled && s.ArrInstance.Enabled) + .ToListAsync(); + + instanceConfigs = instanceConfigs + .Where(s => s.ArrInstance.ArrConfig.Type is InstanceType.Sonarr or InstanceType.Radarr) + .ToList(); + + _logger.LogDebug("CF score sync found {Count} enabled instance(s): {Instances}", + instanceConfigs.Count, + string.Join(", ", instanceConfigs.Select(c => $"{c.ArrInstance.Name} ({c.ArrInstance.ArrConfig.Type})"))); + + if (instanceConfigs.Count == 0) + { + _logger.LogDebug("No enabled instances for CF score sync"); + return; + } + + foreach (SeekerInstanceConfig instanceConfig in instanceConfigs) + { + try + { + await SyncInstanceAsync(instanceConfig.ArrInstance, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sync CF scores for {InstanceName}", + instanceConfig.ArrInstance.Name); + } + } + + await CleanupOldHistoryAsync(); + + await _hubContext.Clients.All.SendAsync("CfScoresUpdated"); + } + + private async Task SyncInstanceAsync(ArrInstance arrInstance, CancellationToken cancellationToken) + { + InstanceType instanceType = arrInstance.ArrConfig.Type; + + _logger.LogDebug("Syncing CF scores for {InstanceType} instance: {InstanceName}", + instanceType, arrInstance.Name); + + if (instanceType == InstanceType.Radarr) + { + await SyncRadarrAsync(arrInstance); + } + else + { + await SyncSonarrAsync(arrInstance, cancellationToken); + } + } + + private async Task SyncRadarrAsync(ArrInstance arrInstance) + { + List profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance); + Dictionary profileMap = profiles.ToDictionary(p => p.Id); + + _logger.LogTrace("[Radarr] {InstanceName}: fetched {ProfileCount} quality profile(s)", + arrInstance.Name, profiles.Count); + + List allMovies = await _radarrClient.GetAllMoviesAsync(arrInstance); + + List<(SearchableMovie Movie, long FileId)> moviesWithFiles = allMovies + .Where(m => m.HasFile && m.MovieFile is not null) + .Select(m => (Movie: m, FileId: m.MovieFile!.Id)) + .Where(x => x.FileId > 0) + .ToList(); + + _logger.LogTrace("[Radarr] {InstanceName}: found {TotalMovies} total movies, {WithFiles} with files", + arrInstance.Name, allMovies.Count, moviesWithFiles.Count); + + DateTime now = _timeProvider.GetUtcNow().UtcDateTime; + + // Touch entries for movies that still exist but have no file (e.g., RSS upgrade in progress) + HashSet movieIdsWithFiles = moviesWithFiles.Select(x => x.Movie.Id).ToHashSet(); + List movieIdsWithoutFiles = allMovies + .Where(m => !movieIdsWithFiles.Contains(m.Id)) + .Select(m => m.Id) + .ToList(); + + foreach (long[] touchChunk in movieIdsWithoutFiles.Chunk(ChunkSize)) + { + List chunkList = touchChunk.ToList(); + await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Radarr + && chunkList.Contains(e.ExternalItemId)) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now)); + } + + int totalSynced = 0; + int totalSkipped = 0; + + foreach ((SearchableMovie Movie, long FileId)[] chunk in moviesWithFiles.Chunk(ChunkSize)) + { + List fileIds = chunk.Select(x => x.FileId).ToList(); + Dictionary scores = await _radarrClient.GetMovieFileScoresAsync(arrInstance, fileIds); + + _logger.LogTrace("[Radarr] {InstanceName}: chunk of {FileCount} file IDs returned {ScoreCount} score(s)", + arrInstance.Name, fileIds.Count, scores.Count); + + List movieIds = chunk.Select(x => x.Movie.Id).ToList(); + Dictionary existingEntries = await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Radarr + && movieIds.Contains(e.ExternalItemId)) + .ToDictionaryAsync(e => e.ExternalItemId); + + foreach ((SearchableMovie movie, long fileId) in chunk) + { + if (!scores.TryGetValue(fileId, out int cfScore)) + { + totalSkipped++; + // Touch existing entry to prevent stale cleanup — movie still exists + if (existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? skippedEntry)) + { + skippedEntry.LastSyncedAt = now; + } + _logger.LogTrace("[Radarr] {InstanceName}: skipping movie '{Title}' (fileId={FileId}) — no score returned", + arrInstance.Name, movie.Title, fileId); + continue; + } + + profileMap.TryGetValue(movie.QualityProfileId, out ArrQualityProfile? profile); + int cutoffScore = profile?.CutoffFormatScore ?? 0; + string profileName = profile?.Name ?? "Unknown"; + + existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? existing); + UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, movie.Monitored, now); + + totalSynced++; + } + + await _dataContext.SaveChangesAsync(); + } + + await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Radarr, now); + + _logger.LogInformation("[Radarr] Synced CF scores for {Count} movies on {InstanceName} ({Skipped} skipped)", + totalSynced, arrInstance.Name, totalSkipped); + } + + private async Task SyncSonarrAsync(ArrInstance arrInstance, CancellationToken cancellationToken) + { + List profiles = await _sonarrClient.GetQualityProfilesAsync(arrInstance); + Dictionary profileMap = profiles.ToDictionary(p => p.Id); + + _logger.LogTrace("[Sonarr] {InstanceName}: fetched {ProfileCount} quality profile(s)", + arrInstance.Name, profiles.Count); + + List allSeries = await _sonarrClient.GetAllSeriesAsync(arrInstance); + + _logger.LogTrace("[Sonarr] {InstanceName}: found {SeriesCount} total series", + arrInstance.Name, allSeries.Count); + + DateTime now = _timeProvider.GetUtcNow().UtcDateTime; + + int totalSynced = 0; + int totalSkipped = 0; + + foreach (SearchableSeries[] chunk in allSeries.Chunk(ChunkSize)) + { + // Collect all episodes with files for this chunk of series + List<(SearchableSeries Series, SearchableEpisode Episode, long FileId, int CfScore, bool IsMonitored)> itemsInChunk = []; + List<(long SeriesId, long EpisodeId)> episodesToTouch = []; + + foreach (SearchableSeries series in chunk) + { + try + { + List episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, series.Id); + List episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, series.Id); + + // Build a map of fileId -> episode file + Dictionary fileMap = episodeFiles.ToDictionary(f => f.Id); + + // Match episodes to their files via EpisodeFileId + int matched = 0; + foreach (SearchableEpisode episode in episodes) + { + if (episode.EpisodeFileId > 0 && fileMap.TryGetValue(episode.EpisodeFileId, out ArrEpisodeFile? file)) + { + itemsInChunk.Add((series, episode, file.Id, file.CustomFormatScore, series.Monitored && episode.Monitored)); + matched++; + } + else + { + // Episode exists but has no file — touch its entry to prevent stale cleanup + episodesToTouch.Add((series.Id, episode.Id)); + if (episode.EpisodeFileId > 0) + { + totalSkipped++; + } + } + } + + _logger.LogTrace("[Sonarr] {InstanceName}: series '{SeriesTitle}' (id={SeriesId}) has {TotalEpisodes} episodes, {FileCount} files, {Matched} matched", + arrInstance.Name, series.Title, series.Id, episodes.Count, episodeFiles.Count, matched); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Sonarr] Failed to fetch episodes for series '{SeriesTitle}' (id={SeriesId}) on {InstanceName}", + series.Title, series.Id, arrInstance.Name); + } + + // Rate limit to avoid overloading the Sonarr API + await Task.Delay(Random.Shared.Next(100, 500), cancellationToken); + } + + if (itemsInChunk.Count > 0) + { + List seriesIds = itemsInChunk.Select(x => x.Series.Id).Distinct().ToList(); + Dictionary<(long, long), CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Sonarr + && seriesIds.Contains(e.ExternalItemId)) + .ToDictionaryAsync(e => (e.ExternalItemId, e.EpisodeId)); + + foreach ((SearchableSeries series, SearchableEpisode episode, long fileId, int cfScore, bool isMonitored) in itemsInChunk) + { + profileMap.TryGetValue(series.QualityProfileId, out ArrQualityProfile? profile); + int cutoffScore = profile?.CutoffFormatScore ?? 0; + string profileName = profile?.Name ?? "Unknown"; + + string title = $"{series.Title} S{episode.SeasonNumber:D2}E{episode.EpisodeNumber:D2}"; + + existingEntries.TryGetValue((series.Id, episode.Id), out CustomFormatScoreEntry? existing); + UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, isMonitored, now); + + totalSynced++; + } + + await _dataContext.SaveChangesAsync(); + } + else + { + _logger.LogTrace("[Sonarr] {InstanceName}: chunk of {ChunkSize} series yielded 0 episodes with files", + arrInstance.Name, chunk.Length); + } + + // Touch entries for episodes that exist but have no file (e.g., RSS upgrade in progress) + foreach (var group in episodesToTouch.GroupBy(x => x.SeriesId)) + { + List episodeIds = group.Select(x => x.EpisodeId).ToList(); + foreach (long[] epChunk in episodeIds.Chunk(ChunkSize)) + { + List epChunkList = epChunk.ToList(); + await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Sonarr + && e.ExternalItemId == group.Key + && epChunkList.Contains(e.EpisodeId)) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now)); + } + } + } + + await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Sonarr, now); + + _logger.LogInformation("[Sonarr] Synced CF scores for {Count} episodes on {InstanceName} ({Skipped} skipped)", + totalSynced, arrInstance.Name, totalSkipped); + } + + /// + /// Creates or updates a CF score entry and records history when the score changes. + /// + private void UpsertCustomFormatScore( + CustomFormatScoreEntry? existing, + Guid arrInstanceId, + long externalItemId, + long episodeId, + InstanceType itemType, + string title, + long fileId, + int cfScore, + int cutoffScore, + string profileName, + bool isMonitored, + DateTime now) + { + if (existing is not null) + { + if (existing.CurrentScore != cfScore) + { + _dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = arrInstanceId, + ExternalItemId = externalItemId, + EpisodeId = episodeId, + ItemType = itemType, + Title = title, + Score = cfScore, + CutoffScore = cutoffScore, + RecordedAt = now, + }); + } + + existing.CurrentScore = cfScore; + existing.CutoffScore = cutoffScore; + existing.FileId = fileId; + existing.QualityProfileName = profileName; + existing.Title = title; + existing.IsMonitored = isMonitored; + existing.LastSyncedAt = now; + } + else + { + _dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = arrInstanceId, + ExternalItemId = externalItemId, + EpisodeId = episodeId, + ItemType = itemType, + Title = title, + FileId = fileId, + CurrentScore = cfScore, + CutoffScore = cutoffScore, + QualityProfileName = profileName, + IsMonitored = isMonitored, + LastSyncedAt = now, + }); + + // Record initial score in history + _dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = arrInstanceId, + ExternalItemId = externalItemId, + EpisodeId = episodeId, + ItemType = itemType, + Title = title, + Score = cfScore, + CutoffScore = cutoffScore, + RecordedAt = now, + }); + } + } + + /// + /// Removes CF score entries and history for items not seen during the current sync. + /// Items that still exist in the *arr app (even without files) have their LastSyncedAt + /// updated during sync, so any entry with LastSyncedAt < syncStartTime is truly removed. + /// + private async Task CleanupStaleEntriesAsync( + Guid arrInstanceId, InstanceType instanceType, DateTime syncStartTime) + { + List staleItemIds = await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstanceId + && e.ItemType == instanceType + && e.LastSyncedAt < syncStartTime) + .Select(e => e.ExternalItemId) + .Distinct() + .ToListAsync(); + + if (staleItemIds.Count == 0) + { + return; + } + + await _dataContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstanceId + && e.ItemType == instanceType + && e.LastSyncedAt < syncStartTime) + .ExecuteDeleteAsync(); + + foreach (long[] chunk in staleItemIds.Chunk(ChunkSize)) + { + List chunkList = chunk.ToList(); + await _dataContext.CustomFormatScoreHistory + .Where(h => h.ArrInstanceId == arrInstanceId + && h.ItemType == instanceType + && chunkList.Contains(h.ExternalItemId)) + .ExecuteDeleteAsync(); + } + + _logger.LogTrace("Cleaned up {Count} stale CF score item(s) for instance {InstanceId}", + staleItemIds.Count, arrInstanceId); + } + + /// + /// Removes CF score history entries older than the retention period + /// + private async Task CleanupOldHistoryAsync() + { + DateTime threshold = _timeProvider.GetUtcNow().UtcDateTime - HistoryRetention; + int deleted = await _dataContext.CustomFormatScoreHistory + .Where(h => h.RecordedAt < threshold) + .ExecuteDeleteAsync(); + + if (deleted > 0) + { + _logger.LogDebug("Cleaned up {Count} CF score history entries older than {Days} days", + deleted, HistoryRetention.Days); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs index a40453cd..9b524058 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/DownloadCleaner.cs @@ -44,7 +44,7 @@ public sealed class DownloadCleaner : GenericHandler _hardLinkFileService = hardLinkFileService; } - protected override async Task ExecuteInternalAsync() + protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default) { var downloadServices = await GetInitializedDownloadServicesAsync(); @@ -101,7 +101,7 @@ public sealed class DownloadCleaner : GenericHandler _logger.LogTrace("Found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count); // wait for the downloads to appear in the arr queue - await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider); + await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Sonarr)), true); await ProcessArrConfigAsync(ContextProvider.Get(nameof(InstanceType.Radarr)), true); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index 076e0b14..8ed2d7c7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -53,7 +53,7 @@ public abstract class GenericHandler : IHandler _dataContext = dataContext; } - public async Task ExecuteAsync() + public async Task ExecuteAsync(CancellationToken cancellationToken = default) { await DataContext.Lock.WaitAsync(); @@ -86,11 +86,11 @@ public abstract class GenericHandler : IHandler { DataContext.Lock.Release(); } - - await ExecuteInternalAsync(); + + await ExecuteInternalAsync(cancellationToken); } - protected abstract Task ExecuteInternalAsync(); + protected abstract Task ExecuteInternalAsync(CancellationToken cancellationToken = default); protected abstract Task ProcessInstanceAsync(ArrInstance instance); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/IHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/IHandler.cs index 6cca13e0..96455c58 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/IHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/IHandler.cs @@ -2,5 +2,5 @@ public interface IHandler { - Task ExecuteAsync(); + Task ExecuteAsync(CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index c5891247..8009a5bc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -40,7 +40,7 @@ public sealed class MalwareBlocker : GenericHandler _blocklistProvider = blocklistProvider; } - protected override async Task ExecuteInternalAsync() + protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default) { if (ContextProvider.Get>(nameof(DownloadClientConfig)).Count is 0) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs index 4e11b228..252f9f9e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs @@ -36,7 +36,7 @@ public sealed class QueueCleaner : GenericHandler { } - protected override async Task ExecuteInternalAsync() + protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default) { List stallRules = await _dataContext.StallRules .Where(r => r.Enabled) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs new file mode 100644 index 00000000..e3f61d6b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -0,0 +1,928 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.Seeker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Cleanuparr.Persistence.Models.State; +using Cleanuparr.Infrastructure.Hubs; +using Data.Models.Arr; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.Jobs; + +public sealed class Seeker : IHandler +{ + private const double JitterFactor = 0.2; + private const int MinJitterSeconds = 30; + private const int MaxJitterSeconds = 120; + + /// + /// Queue states that indicate an item is actively being processed. + /// Items in these states are excluded from proactive searches. + /// "importFailed" is intentionally excluded — failed imports should be re-searched. + /// + private static readonly HashSet ActiveQueueStates = new(StringComparer.OrdinalIgnoreCase) + { + "downloading", + "importing", + "importPending", + "importBlocked" + }; + + private readonly ILogger _logger; + private readonly DataContext _dataContext; + private readonly IRadarrClient _radarrClient; + private readonly ISonarrClient _sonarrClient; + private readonly IArrClientFactory _arrClientFactory; + private readonly IArrQueueIterator _arrQueueIterator; + private readonly IEventPublisher _eventPublisher; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly IHostingEnvironment _environment; + private readonly TimeProvider _timeProvider; + private readonly IHubContext _hubContext; + + public Seeker( + ILogger logger, + DataContext dataContext, + IRadarrClient radarrClient, + ISonarrClient sonarrClient, + IArrClientFactory arrClientFactory, + IArrQueueIterator arrQueueIterator, + IEventPublisher eventPublisher, + IDryRunInterceptor dryRunInterceptor, + IHostingEnvironment environment, + TimeProvider timeProvider, + IHubContext hubContext) + { + _logger = logger; + _dataContext = dataContext; + _radarrClient = radarrClient; + _sonarrClient = sonarrClient; + _arrClientFactory = arrClientFactory; + _arrQueueIterator = arrQueueIterator; + _eventPublisher = eventPublisher; + _dryRunInterceptor = dryRunInterceptor; + _environment = environment; + _timeProvider = timeProvider; + _hubContext = hubContext; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + SeekerConfig config = await _dataContext.SeekerConfigs + .AsNoTracking() + .FirstAsync(); + + if (!config.SearchEnabled) + { + _logger.LogDebug("Search is disabled"); + return; + } + + await ApplyJitter(config, cancellationToken); + + bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + + // Replacement searches queued after download removal + SearchQueueItem? replacementItem = await _dataContext.SearchQueue + .OrderBy(q => q.CreatedAt) + .FirstOrDefaultAsync(); + + if (replacementItem is not null) + { + await ProcessReplacementItemAsync(replacementItem, isDryRun); + await _hubContext.Clients.All.SendAsync("SearchStatsUpdated"); + return; + } + + // Missing items and quality upgrades + if (!config.ProactiveSearchEnabled) + { + return; + } + + await ProcessProactiveSearchAsync(config, isDryRun); + + await _hubContext.Clients.All.SendAsync("SearchStatsUpdated"); + } + + private async Task ApplyJitter(SeekerConfig config, CancellationToken cancellationToken) + { + if (_environment.IsDevelopment()) + { + return; + } + + int proportionalJitter = (int)(config.SearchInterval * 60 * JitterFactor); + int maxJitterSeconds = Math.Clamp(proportionalJitter, MinJitterSeconds, MaxJitterSeconds); + int jitterSeconds = Random.Shared.Next(0, maxJitterSeconds + 1); + + if (jitterSeconds > 0) + { + _logger.LogDebug("Waiting {Jitter}s before searching", jitterSeconds); + await Task.Delay(TimeSpan.FromSeconds(jitterSeconds), _timeProvider, cancellationToken); + } + } + + private async Task ProcessReplacementItemAsync(SearchQueueItem item, bool isDryRun) + { + ArrInstance? arrInstance = await _dataContext.ArrInstances + .Include(a => a.ArrConfig) + .FirstOrDefaultAsync(a => a.Id == item.ArrInstanceId); + + if (arrInstance is null) + { + _logger.LogWarning( + "Skipping replacement search for '{Title}' — arr instance {InstanceId} no longer exists", + item.Title, item.ArrInstanceId); + _dataContext.SearchQueue.Remove(item); + await _dataContext.SaveChangesAsync(); + return; + } + + ContextProvider.Set(nameof(InstanceType), item.ArrInstance.ArrConfig.Type); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url); + + try + { + IArrClient arrClient = _arrClientFactory.GetClient(item.ArrInstance.ArrConfig.Type, arrInstance.Version); + HashSet searchItems = BuildSearchItems(item); + + List commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems); + + Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, 1, [item.Title], SeekerSearchType.Replacement); + + if (!isDryRun) + { + await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, item.ArrInstance.ArrConfig.Type, item.ItemId, item.Title); + } + + _logger.LogInformation("Replacement search triggered for '{Title}' on {InstanceName}", + item.Title, arrInstance.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process replacement search for '{Title}' on {InstanceName}", + item.Title, arrInstance.Name); + } + finally + { + if (!isDryRun) + { + _dataContext.SearchQueue.Remove(item); + await _dataContext.SaveChangesAsync(); + } + } + } + + private static HashSet BuildSearchItems(SearchQueueItem item) + { + if (item.SeriesId.HasValue && Enum.TryParse(item.SearchType, out var searchType)) + { + return + [ + new SeriesSearchItem + { + Id = item.ItemId, + SeriesId = item.SeriesId.Value, + SearchType = searchType + } + ]; + } + + return [new SearchItem { Id = item.ItemId }]; + } + + private async Task ProcessProactiveSearchAsync(SeekerConfig config, bool isDryRun) + { + List instanceConfigs = await _dataContext.SeekerInstanceConfigs + .Include(s => s.ArrInstance) + .ThenInclude(a => a.ArrConfig) + .Where(s => s.Enabled && s.ArrInstance.Enabled) + .ToListAsync(); + + instanceConfigs = instanceConfigs + .Where(s => s.ArrInstance.ArrConfig.Type is InstanceType.Sonarr or InstanceType.Radarr) + .ToList(); + + if (instanceConfigs.Count == 0) + { + _logger.LogDebug("No enabled Seeker instances found for proactive search"); + return; + } + + if (config.UseRoundRobin) + { + // Round-robin: try instances in order of oldest LastProcessedAt, + // stop after the first one that triggers a search. + // This prevents cycle-complete-waiting instances from wasting a run. + var ordered = instanceConfigs + .OrderBy(s => s.LastProcessedAt ?? DateTime.MinValue) + .ToList(); + + foreach (SeekerInstanceConfig instance in ordered) + { + bool searched = await ProcessSingleInstanceAsync(config, instance, isDryRun); + + if (searched) + { + break; + } + } + } + else + { + // Process all enabled instances sequentially + foreach (SeekerInstanceConfig instanceConfig in instanceConfigs) + { + await ProcessSingleInstanceAsync(config, instanceConfig, isDryRun); + } + } + } + + private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun) + { + ArrInstance arrInstance = instanceConfig.ArrInstance; + InstanceType instanceType = arrInstance.ArrConfig.Type; + + _logger.LogDebug("Processing {InstanceType} instance: {InstanceName}", + instanceType, arrInstance.Name); + + // Set context for event publishing + ContextProvider.Set(nameof(InstanceType), instanceType); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url); + + // Fetch queue once for both active download limit check and queue cross-referencing + IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version); + List queueRecords = []; + + try + { + await _arrQueueIterator.Iterate(arrClient, arrInstance, records => + { + queueRecords.AddRange(records); + return Task.CompletedTask; + }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch queue for {InstanceName}, proceeding without queue cross-referencing", + arrInstance.Name); + } + + // Check active download limit using the fetched queue data + if (instanceConfig.ActiveDownloadLimit > 0) + { + int activeDownloads = queueRecords.Count(r => r.SizeLeft > 0); + if (activeDownloads >= instanceConfig.ActiveDownloadLimit) + { + _logger.LogInformation( + "Skipping proactive search for {InstanceName} — {Count} items actively downloading (limit: {Limit})", + arrInstance.Name, activeDownloads, instanceConfig.ActiveDownloadLimit); + return false; + } + } + + bool searched = false; + try + { + searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process {InstanceType} instance: {InstanceName}", + instanceType, arrInstance.Name); + } + + // Update LastProcessedAt so round-robin moves on + instanceConfig.LastProcessedAt = _timeProvider.GetUtcNow().UtcDateTime; + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + + return searched; + } + + private async Task ProcessInstanceAsync( + SeekerConfig config, + SeekerInstanceConfig instanceConfig, + ArrInstance arrInstance, + InstanceType instanceType, + bool isDryRun, + List queueRecords) + { + // Load search history for the current cycle + List currentCycleHistory = await _dataContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == arrInstance.Id && h.CycleId == instanceConfig.CurrentCycleId) + .ToListAsync(); + + // Load all history for stale cleanup + List allHistoryExternalIds = await _dataContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == arrInstance.Id) + .Select(x => x.ExternalItemId) + .ToListAsync(); + + // Derive item-level history for selection strategies + Dictionary itemSearchHistory = currentCycleHistory + .GroupBy(h => h.ExternalItemId) + .ToDictionary(g => g.Key, g => g.Max(h => h.LastSearchedAt)); + + // Build queued-item lookups from active queue records + var activeQueueRecords = queueRecords + .Where(r => ActiveQueueStates.Contains(r.TrackedDownloadState)) + .ToList(); + + HashSet searchItems; + List selectedNames; + List allLibraryIds; + List historyIds; + int seasonNumber = 0; + + if (instanceType == InstanceType.Radarr) + { + HashSet queuedMovieIds = activeQueueRecords + .Where(r => r.MovieId > 0) + .Select(r => r.MovieId) + .ToHashSet(); + + List selectedIds; + (selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds); + searchItems = selectedIds.Select(id => new SearchItem { Id = id }).ToHashSet(); + historyIds = selectedIds; + } + else + { + HashSet<(long SeriesId, long SeasonNumber)> queuedSeasons = activeQueueRecords + .Where(r => r.SeriesId > 0) + .Select(r => (r.SeriesId, r.SeasonNumber)) + .ToHashSet(); + + (searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) = + await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons); + } + + if (searchItems.Count == 0) + { + _logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name); + if (!isDryRun) + { + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds); + } + return false; + } + + // Trigger search (arr client guards the HTTP request via dry run interceptor) + IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version); + List commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems); + + // Publish event (always saved, flagged with IsDryRun in EventPublisher) + Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, searchItems.Count, selectedNames, SeekerSearchType.Proactive, instanceConfig.CurrentCycleId); + + _logger.LogInformation("Searched {Count} items on {InstanceName}: {Items}", + searchItems.Count, arrInstance.Name, string.Join(", ", selectedNames)); + + // Update search history (always, so stats are accurate during dry run) + await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, historyIds, selectedNames, seasonNumber, isDryRun); + + if (!isDryRun) + { + // Track commands + long externalItemId = historyIds.FirstOrDefault(); + string itemTitle = selectedNames.FirstOrDefault() ?? string.Empty; + await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType, externalItemId, itemTitle, seasonNumber); + + // Cleanup stale history entries and old cycle history + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds); + await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId); + } + + return true; + } + + private async Task<(List SelectedIds, List SelectedNames, List AllLibraryIds)> ProcessRadarrAsync( + SeekerConfig config, + ArrInstance arrInstance, + SeekerInstanceConfig instanceConfig, + Dictionary searchHistory, + bool isDryRun, + HashSet queuedMovieIds) + { + List movies = await _radarrClient.GetAllMoviesAsync(arrInstance); + List allLibraryIds = movies.Select(m => m.Id).ToList(); + + // Load cached CF scores when custom format score filtering is enabled + Dictionary? cfScores = null; + if (config.UseCustomFormatScore) + { + cfScores = await _dataContext.CustomFormatScoreEntries + .AsNoTracking() + .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Radarr) + .ToDictionaryAsync(e => e.ExternalItemId); + } + + // Apply filters — UseCutoff and UseCustomFormatScore are OR-ed: an item qualifies if it fails the quality cutoff OR the CF score cutoff. + // Items without cutoff data or a cached CF score are excluded from the respective filter. + DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours); + var candidates = movies + .Where(m => m.Status is "released") + .Where(m => IsMoviePastGracePeriod(m, graceCutoff)) + .Where(m => !config.MonitoredOnly || m.Monitored) + .Where(m => instanceConfig.SkipTags.Count == 0 || !m.Tags.Any(instanceConfig.SkipTags.Contains)) + .Where(m => !m.HasFile + || (!config.UseCutoff && !config.UseCustomFormatScore) + || (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? false)) + || (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(m.Id, out var entry) && entry.CurrentScore < entry.CutoffScore)) + .ToList(); + + instanceConfig.TotalEligibleItems = candidates.Count; + + if (candidates.Count == 0) + { + return ([], [], allLibraryIds); + } + + // Exclude movies already in the download queue + if (queuedMovieIds.Count > 0) + { + int beforeCount = candidates.Count; + candidates = candidates + .Where(m => !queuedMovieIds.Contains(m.Id)) + .ToList(); + + int skipped = beforeCount - candidates.Count; + if (skipped > 0) + { + _logger.LogDebug("Excluded {Count} movies already in queue on {InstanceName}", + skipped, arrInstance.Name); + } + + if (candidates.Count == 0) + { + return ([], [], allLibraryIds); + } + } + + // Check for cycle completion: all candidates already searched in current cycle + bool cycleComplete = candidates.All(m => searchHistory.ContainsKey(m.Id)); + if (cycleComplete) + { + // Respect MinCycleTimeDays even when cycle completes due to queue filtering + DateTime? cycleStartedAt = searchHistory.Count > 0 ? searchHistory.Values.Min() : null; + if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt)) + { + _logger.LogDebug( + "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", + instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); + return ([], [], allLibraryIds); + } + + _logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle", + candidates.Count, arrInstance.Name); + + if (!isDryRun) + { + instanceConfig.CurrentCycleId = Guid.NewGuid(); + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + } + + searchHistory = new Dictionary(); + } + + // Only pass unsearched items to the selector — already-searched items in this cycle are skipped + var selectionCandidates = candidates + .Where(m => !searchHistory.ContainsKey(m.Id)) + .Select(m => (m.Id, m.Added, LastSearched: (DateTime?)null)) + .ToList(); + + IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy); + List selectedIds = selector.Select(selectionCandidates, 1); + + List selectedNames = candidates + .Where(m => selectedIds.Contains(m.Id)) + .Select(m => m.Title) + .ToList(); + + foreach (long movieId in selectedIds) + { + SearchableMovie movie = candidates.First(m => m.Id == movieId); + string reason = !movie.HasFile + ? "missing file" + : config.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false) + ? "does not meet quality cutoff" + : "custom format score below cutoff"; + _logger.LogDebug("Selected '{Title}' for search on {InstanceName}: {Reason}", + movie.Title, arrInstance.Name, reason); + } + + return (selectedIds, selectedNames, allLibraryIds); + } + + private async Task<(HashSet SearchItems, List SelectedNames, List AllLibraryIds, List HistoryIds, int SeasonNumber)> ProcessSonarrAsync( + SeekerConfig config, + ArrInstance arrInstance, + SeekerInstanceConfig instanceConfig, + Dictionary seriesSearchHistory, + List currentCycleHistory, + bool isDryRun, + bool isRetry = false, + HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null) + { + List series = await _sonarrClient.GetAllSeriesAsync(arrInstance); + List allLibraryIds = series.Select(s => s.Id).ToList(); + DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours); + + // Apply filters + var candidates = series + .Where(s => s.Status is "continuing" or "ended" or "released") + .Where(s => !config.MonitoredOnly || s.Monitored) + .Where(s => instanceConfig.SkipTags.Count == 0 || !s.Tags.Any(instanceConfig.SkipTags.Contains)) + // Skip fully-downloaded series (unless quality upgrade filters active) + .Where(s => config.UseCutoff || config.UseCustomFormatScore + || s.Statistics == null || s.Statistics.EpisodeCount == 0 + || s.Statistics.EpisodeFileCount < s.Statistics.EpisodeCount) + .ToList(); + + instanceConfig.TotalEligibleItems = candidates.Count; + + if (candidates.Count == 0) + { + return ([], [], allLibraryIds, [], 0); + } + + // Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion + // LastSearched info helps the selector deprioritize recently-searched series + var selectionCandidates = candidates + .Select(s => (s.Id, s.Added, LastSearched: seriesSearchHistory.TryGetValue(s.Id, out var dt) ? (DateTime?)dt : null)) + .ToList(); + + // Select all candidates in priority order so the loop can find one with unsearched seasons + IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy); + List candidateIds = selector.Select(selectionCandidates, selectionCandidates.Count); + + // Drill down to find the first series with qualifying unsearched seasons + foreach (long seriesId in candidateIds) + { + string seriesTitle = string.Empty; + + try + { + List seriesHistory = currentCycleHistory + .Where(h => h.ExternalItemId == seriesId) + .ToList(); + + seriesTitle = candidates.First(s => s.Id == seriesId).Title; + + (SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode) = + await BuildSonarrSearchItemAsync(config, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff, queuedSeasons); + + if (searchItem is not null) + { + string displayName = $"{seriesTitle} S{searchItem.Id:D2}"; + int seasonNumber = (int)searchItem.Id; + + return ([searchItem], [displayName], allLibraryIds, [seriesId], seasonNumber); + } + + _logger.LogDebug("Skipping '{SeriesTitle}' — no qualifying seasons found", seriesTitle); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check episodes for '{SeriesTitle}', skipping", seriesTitle); + } + } + + // All candidates were tried and none had qualifying unsearched seasons — cycle complete + if (candidates.Count > 0 && !isRetry) + { + // Respect MinCycleTimeDays even when cycle completes due to queue filtering + DateTime? cycleStartedAt = seriesSearchHistory.Count > 0 ? seriesSearchHistory.Values.Min() : null; + if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt)) + { + _logger.LogDebug( + "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", + instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); + return ([], [], allLibraryIds, [], 0); + } + + _logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle", + candidates.Count, arrInstance.Name); + if (!isDryRun) + { + instanceConfig.CurrentCycleId = Guid.NewGuid(); + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + } + + // Retry with fresh cycle (only once to prevent infinite recursion) + return await ProcessSonarrAsync(config, arrInstance, instanceConfig, + new Dictionary(), [], isDryRun, isRetry: true, queuedSeasons: queuedSeasons); + } + + return ([], [], allLibraryIds, [], 0); + } + + /// + /// Fetches episodes for a series and builds a season-level search item. + /// Uses search history to prefer least-recently-searched seasons. + /// + private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode)> BuildSonarrSearchItemAsync( + SeekerConfig config, + ArrInstance arrInstance, + long seriesId, + List seriesHistory, + string seriesTitle, + DateTime graceCutoff, + HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null) + { + List episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, seriesId); + + // Fetch episode file metadata to determine cutoff status from the dedicated episodefile endpoint + HashSet cutoffNotMetFileIds = []; + if (config.UseCutoff) + { + List episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, seriesId); + cutoffNotMetFileIds = episodeFiles + .Where(f => f.QualityCutoffNotMet) + .Select(f => f.Id) + .ToHashSet(); + } + + // Load cached CF scores for this series when custom format score filtering is enabled + Dictionary? cfScores = null; + if (config.UseCustomFormatScore) + { + cfScores = await _dataContext.CustomFormatScoreEntries + .AsNoTracking() + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == InstanceType.Sonarr + && e.ExternalItemId == seriesId) + .ToDictionaryAsync(e => e.EpisodeId); + } + + // Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed. + // Cutoff status comes from the episodefile endpoint; items without a cached CF score are excluded. + var qualifying = episodes + .Where(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff) + .Where(e => !config.MonitoredOnly || e.Monitored) + .Where(e => !e.HasFile + || (!config.UseCutoff && !config.UseCustomFormatScore) + || (config.UseCutoff && cutoffNotMetFileIds.Contains(e.EpisodeFileId)) + || (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(e.Id, out var entry) && entry.CurrentScore < entry.CutoffScore)) + .OrderBy(e => e.SeasonNumber) + .ThenBy(e => e.EpisodeNumber) + .ToList(); + + if (qualifying.Count == 0) + { + return (null, null); + } + + // Select least-recently-searched season using history + var seasonGroups = qualifying + .GroupBy(e => e.SeasonNumber) + .Select(g => + { + DateTime? lastSearched = seriesHistory + .FirstOrDefault(h => h.SeasonNumber == g.Key) + ?.LastSearchedAt; + return (SeasonNumber: g.Key, LastSearched: lastSearched, FirstEpisode: g.First()); + }) + .ToList(); + + // Find unsearched seasons first + var unsearched = seasonGroups.Where(s => s.LastSearched is null).ToList(); + + // Exclude seasons already in the download queue + if (queuedSeasons is { Count: > 0 }) + { + int beforeCount = unsearched.Count; + unsearched = unsearched + .Where(s => !queuedSeasons.Contains((seriesId, (long)s.SeasonNumber))) + .ToList(); + + int skipped = beforeCount - unsearched.Count; + if (skipped > 0) + { + _logger.LogDebug("Excluded {Count} seasons already in queue for '{SeriesTitle}' on {InstanceName}", + skipped, seriesTitle, arrInstance.Name); + } + } + + if (unsearched.Count == 0) + { + // All unsearched seasons are either searched or in the queue + return (null, null); + } + + // Pick from unsearched seasons with some randomization + var selected = unsearched + .OrderBy(_ => Random.Shared.Next()) + .First(); + + // Log why this season was selected + var seasonEpisodes = qualifying.Where(e => e.SeasonNumber == selected.SeasonNumber).ToList(); + int missingCount = seasonEpisodes.Count(e => !e.HasFile); + int cutoffCount = seasonEpisodes.Count(e => e.HasFile && cutoffNotMetFileIds.Contains(e.EpisodeFileId)); + int cfCount = seasonEpisodes.Count(e => e.HasFile && cfScores != null + && cfScores.TryGetValue(e.Id, out var cfEntry) && cfEntry.CurrentScore < cfEntry.CutoffScore); + + List reasons = []; + if (missingCount > 0) + { + reasons.Add($"{missingCount} missing"); + } + + if (cutoffCount > 0) + { + reasons.Add($"{cutoffCount} cutoff unmet"); + } + + if (cfCount > 0) + { + reasons.Add($"{cfCount} below CF score cutoff"); + } + + _logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}", + seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons)); + + SeriesSearchItem searchItem = new() + { + Id = selected.SeasonNumber, + SeriesId = seriesId, + SearchType = SeriesSearchType.Season + }; + + return (searchItem, selected.FirstEpisode); + } + + private async Task UpdateSearchHistoryAsync( + Guid arrInstanceId, + InstanceType instanceType, + Guid cycleId, + List searchedIds, + List? itemTitles = null, + int seasonNumber = 0, + bool isDryRun = false) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + + for (int i = 0; i < searchedIds.Count; i++) + { + long id = searchedIds[i]; + string title = itemTitles != null && i < itemTitles.Count ? itemTitles[i] : string.Empty; + + SeekerHistory? existing = await _dataContext.SeekerHistory + .FirstOrDefaultAsync(h => + h.ArrInstanceId == arrInstanceId + && h.ExternalItemId == id + && h.ItemType == instanceType + && h.SeasonNumber == seasonNumber + && h.CycleId == cycleId); + + if (existing is not null) + { + existing.LastSearchedAt = now; + existing.SearchCount++; + if (!string.IsNullOrEmpty(title)) + { + existing.ItemTitle = title; + } + } + else + { + _dataContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = arrInstanceId, + ExternalItemId = id, + ItemType = instanceType, + SeasonNumber = seasonNumber, + CycleId = cycleId, + LastSearchedAt = now, + ItemTitle = title, + IsDryRun = isDryRun, + }); + } + } + + await _dataContext.SaveChangesAsync(); + } + + private async Task SaveCommandTrackersAsync( + List commandIds, + Guid eventId, + Guid arrInstanceId, + InstanceType instanceType, + long externalItemId, + string itemTitle, + int seasonNumber = 0) + { + if (commandIds.Count == 0) + { + return; + } + + foreach (long commandId in commandIds) + { + _dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker + { + ArrInstanceId = arrInstanceId, + CommandId = commandId, + EventId = eventId, + ExternalItemId = externalItemId, + ItemTitle = itemTitle, + ItemType = instanceType, + SeasonNumber = seasonNumber, + }); + } + + await _dataContext.SaveChangesAsync(); + } + + private async Task CleanupStaleHistoryAsync( + Guid arrInstanceId, + InstanceType instanceType, + List currentLibraryIds, + IEnumerable historyExternalIds) + { + // Find history entries for items no longer in the library + var staleIds = historyExternalIds + .Except(currentLibraryIds) + .Distinct() + .ToList(); + + if (staleIds.Count == 0) + { + return; + } + + await _dataContext.SeekerHistory + .Where(h => h.ArrInstanceId == arrInstanceId + && h.ItemType == instanceType + && staleIds.Contains(h.ExternalItemId)) + .ExecuteDeleteAsync(); + + _logger.LogDebug( + "Cleaned up {Count} stale Seeker history entries for instance {InstanceId}", + staleIds.Count, + arrInstanceId + ); + } + + /// + /// Removes history entries from previous cycles that are older than 30 days. + /// Recent cycle history is retained for statistics and history viewing. + /// + private async Task CleanupOldCycleHistoryAsync(ArrInstance arrInstance, Guid currentCycleId) + { + DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + + int deleted = await _dataContext.SeekerHistory + .Where(h => h.ArrInstanceId == arrInstance.Id + && h.CycleId != currentCycleId + && h.LastSearchedAt < cutoff) + .ExecuteDeleteAsync(); + + if (deleted > 0) + { + _logger.LogDebug("Cleaned up {Count} old cycle history entries (>30 days) for instance {InstanceName}", + deleted, arrInstance.Name); + } + } + + /// + /// Checks whether the minimum cycle time constraint prevents starting a new cycle. + /// Returns true if the cycle started recently and MinCycleTimeDays has not yet elapsed. + /// + private bool ShouldWaitForMinCycleTime(SeekerInstanceConfig instanceConfig, DateTime? cycleStartedAt) + { + if (cycleStartedAt is null) + { + return false; + } + + var elapsed = _timeProvider.GetUtcNow().UtcDateTime - cycleStartedAt.Value; + return elapsed.TotalDays < instanceConfig.MinCycleTimeDays; + } + + /// + /// Returns true when the movie's release date is past the grace period cutoff. + /// Movies without any release date info are treated as released. + /// + private static bool IsMoviePastGracePeriod(SearchableMovie movie, DateTime graceCutoff) + { + DateTime? releaseDate = movie.DigitalRelease ?? movie.PhysicalRelease ?? movie.InCinemas; + return releaseDate is null || releaseDate.Value <= graceCutoff; + } + +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs new file mode 100644 index 00000000..81ad4cd6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs @@ -0,0 +1,231 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.State; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Cleanuparr.Infrastructure.Features.Jobs; + +/// +/// Background service that polls arr command status for pending search commands +/// and inspects the download queue for grabbed items after completion. +/// +public class SeekerCommandMonitor : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15); + private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10); + + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + + public SeekerCommandMonitor( + ILogger logger, + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider) + { + _logger = logger; + _scopeFactory = scopeFactory; + _timeProvider = timeProvider; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Wait for app startup + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + bool hadWork = await ProcessPendingCommandsAsync(stoppingToken); + await Task.Delay(hadWork ? PollInterval : IdleInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SeekerCommandMonitor"); + await Task.Delay(IdleInterval, stoppingToken); + } + } + } + + private async Task ProcessPendingCommandsAsync(CancellationToken stoppingToken) + { + using IServiceScope scope = _scopeFactory.CreateScope(); + var dataContext = scope.ServiceProvider.GetRequiredService(); + var arrClientFactory = scope.ServiceProvider.GetRequiredService(); + var eventPublisher = scope.ServiceProvider.GetRequiredService(); + + List pendingTrackers = await dataContext.SeekerCommandTrackers + .Include(t => t.ArrInstance) + .ThenInclude(a => a.ArrConfig) + .Where(t => t.Status != SearchCommandStatus.Completed + && t.Status != SearchCommandStatus.Failed + && t.Status != SearchCommandStatus.TimedOut) + .ToListAsync(stoppingToken); + + if (pendingTrackers.Count == 0) + { + return false; + } + + // Handle timed-out commands + var timedOut = pendingTrackers + .Where(t => _timeProvider.GetUtcNow().UtcDateTime - t.CreatedAt > CommandTimeout) + .ToList(); + + foreach (var tracker in timedOut) + { + _logger.LogWarning("Search command {CommandId} timed out for '{Title}' on {Instance}", + tracker.CommandId, tracker.ItemTitle, tracker.ArrInstance.Name); + tracker.Status = SearchCommandStatus.TimedOut; + } + + // Group remaining by event ID for batch processing + var activeTrackers = pendingTrackers.Except(timedOut).ToList(); + var trackersByInstance = activeTrackers.GroupBy(t => t.ArrInstanceId); + + foreach (var instanceGroup in trackersByInstance) + { + var arrInstance = instanceGroup.First().ArrInstance; + IArrClient arrClient = arrClientFactory.GetClient(arrInstance.ArrConfig.Type, arrInstance.Version); + + foreach (var tracker in instanceGroup) + { + try + { + ArrCommandStatus status = await arrClient.GetCommandStatusAsync(arrInstance, tracker.CommandId); + UpdateTrackerStatus(tracker, status); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check command {CommandId} status on {Instance}", + tracker.CommandId, arrInstance.Name); + } + } + } + + await dataContext.SaveChangesAsync(stoppingToken); + + // Process completed/failed events + var allTrackers = await dataContext.SeekerCommandTrackers + .Include(t => t.ArrInstance) + .ThenInclude(a => a.ArrConfig) + .ToListAsync(stoppingToken); + + var trackersByEvent = allTrackers.GroupBy(t => t.EventId); + + foreach (var eventGroup in trackersByEvent) + { + Guid eventId = eventGroup.Key; + var trackers = eventGroup.ToList(); + + bool allTerminal = trackers.All(t => + t.Status is SearchCommandStatus.Completed + or SearchCommandStatus.Failed + or SearchCommandStatus.TimedOut); + + if (!allTerminal) + { + continue; + } + + bool anyFailed = trackers.Any(t => t.Status is SearchCommandStatus.Failed or SearchCommandStatus.TimedOut); + + if (anyFailed) + { + await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Failed); + _logger.LogWarning("Search command(s) failed for event {EventId}", eventId); + } + else + { + // All completed — inspect download queue for grabbed items + object? resultData = await InspectDownloadQueueAsync(trackers, arrClientFactory); + await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData); + _logger.LogDebug("Search command(s) completed for event {EventId}", eventId); + } + + // Remove processed trackers + dataContext.SeekerCommandTrackers.RemoveRange(trackers); + } + + await dataContext.SaveChangesAsync(stoppingToken); + return true; + } + + private static void UpdateTrackerStatus(SeekerCommandTracker tracker, ArrCommandStatus commandStatus) + { + tracker.Status = commandStatus.Status.ToLowerInvariant() switch + { + "completed" => SearchCommandStatus.Completed, + "failed" => SearchCommandStatus.Failed, + "started" => SearchCommandStatus.Started, + _ => tracker.Status // Keep current status for queued/other states + }; + } + + private async Task InspectDownloadQueueAsync( + List trackers, + IArrClientFactory arrClientFactory) + { + var allGrabbedItems = new List(); + + // Group by instance to inspect each instance's queue separately + foreach (var instanceGroup in trackers.GroupBy(t => t.ArrInstanceId)) + { + try + { + var tracker = instanceGroup.First(); + var arrInstance = tracker.ArrInstance; + IArrClient arrClient = arrClientFactory.GetClient(arrInstance.ArrConfig.Type, arrInstance.Version); + + // Fetch the first page of the queue + QueueListResponse queue = await arrClient.GetQueueItemsAsync(arrInstance, 1); + + // Find records matching any tracker in this instance group + foreach (var t in instanceGroup) + { + var grabbedItems = queue.Records + .Where(r => t.ItemType == InstanceType.Radarr + ? r.MovieId == t.ExternalItemId + : r.SeriesId == t.ExternalItemId + && (t.SeasonNumber == 0 || r.SeasonNumber == t.SeasonNumber)) + .Select(r => new + { + r.Title, + r.Status, + r.Protocol, + }) + .ToList(); + + if (grabbedItems.Count > 0) + { + _logger.LogInformation("Search for '{Title}' on {Instance} grabbed {Count} items: {Items}", + t.ItemTitle, arrInstance.Name, grabbedItems.Count, + string.Join(", ", grabbedItems.Select(g => g.Title))); + + allGrabbedItems.AddRange(grabbedItems); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to inspect download queue after search completion"); + } + } + + return allGrabbedItems.Count > 0 ? new { GrabbedItems = allGrabbedItems } : null; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs index 47713553..610de596 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs @@ -11,4 +11,6 @@ public interface INotificationPublisher Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason); Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false); + + Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable items); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs index 4d0ebac8..83e2934c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs @@ -13,4 +13,6 @@ public sealed record NotificationEventFlags public bool OnDownloadCleaned { get; init; } public bool OnCategoryChanged { get; init; } + + public bool OnSearchTriggered { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs index 06395659..41e29fbb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs @@ -131,7 +131,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio OnSlowStrike = config.OnSlowStrike, OnQueueItemDeleted = config.OnQueueItemDeleted, OnDownloadCleaned = config.OnDownloadCleaned, - OnCategoryChanged = config.OnCategoryChanged + OnCategoryChanged = config.OnCategoryChanged, + OnSearchTriggered = config.OnSearchTriggered }; var configuration = config.Type switch @@ -167,6 +168,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio NotificationEventType.QueueItemDeleted => events.OnQueueItemDeleted, NotificationEventType.DownloadCleaned => events.OnDownloadCleaned, NotificationEventType.CategoryChanged => events.OnCategoryChanged, + NotificationEventType.SearchTriggered => events.OnSearchTriggered, NotificationEventType.Test => true, _ => throw new ArgumentOutOfRangeException(nameof(eventType), $"Provider type {eventType} is not yet registered") }; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index eac16e07..e34ff2a1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -82,6 +82,19 @@ public class NotificationPublisher : INotificationPublisher } } + public virtual async Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable items) + { + try + { + var context = BuildSearchTriggeredContext(instanceName, itemCount, items); + await SendNotificationAsync(NotificationEventType.SearchTriggered, context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to notify search triggered"); + } + } + private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context) { await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context)); @@ -229,6 +242,29 @@ public class NotificationPublisher : INotificationPublisher return context; } + private static NotificationContext BuildSearchTriggeredContext(string instanceName, int itemCount, IEnumerable items) + { + var instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); + var instanceUrl = ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl); + var itemList = items as string[] ?? items.ToArray(); + var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : ""); + + return new NotificationContext + { + EventType = NotificationEventType.SearchTriggered, + Title = "Search triggered", + Description = $"Searched {itemCount} items on {instanceName}", + Severity = EventSeverity.Information, + Data = new Dictionary + { + ["Instance type"] = instanceType.ToString(), + ["Url"] = instanceUrl.ToString(), + ["Item count"] = itemCount.ToString(), + ["Items"] = itemsDisplay, + } + }; + } + private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType) { return strikeType switch diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs new file mode 100644 index 00000000..6340368f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/IItemSelector.cs @@ -0,0 +1,15 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// Interface for selecting items to search based on a strategy +/// +public interface IItemSelector +{ + /// + /// Selects up to item IDs from the candidates + /// + /// List of (id, dateAdded, lastSearched) tuples + /// Maximum number of items to select + /// Selected item IDs + List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs new file mode 100644 index 00000000..c818c0a2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/ItemSelectorFactory.cs @@ -0,0 +1,24 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// Factory that returns the appropriate item selector based on the configured strategy +/// +public static class ItemSelectorFactory +{ + public static IItemSelector Create(SelectionStrategy strategy) + { + return strategy switch + { + SelectionStrategy.OldestSearchFirst => new OldestSearchFirstSelector(), + SelectionStrategy.OldestSearchWeighted => new OldestSearchWeightedSelector(), + SelectionStrategy.NewestFirst => new NewestFirstSelector(), + SelectionStrategy.NewestWeighted => new NewestWeightedSelector(), + SelectionStrategy.BalancedWeighted => new BalancedWeightedSelector(), + SelectionStrategy.Random => new RandomSelector(), + _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown selection strategy") + }; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs new file mode 100644 index 00000000..53bec8e4 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/BalancedWeightedSelector.cs @@ -0,0 +1,66 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items using weighted random sampling that combines search recency and add date. +/// Each item is ranked on both dimensions, and the average rank determines its weight. +/// Items that are both recently added and haven't been searched get the highest combined weight. +/// +public sealed class BalancedWeightedSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + if (candidates.Count == 0) + { + return []; + } + + count = Math.Min(count, candidates.Count); + int n = candidates.Count; + + // Rank by search recency: never-searched / oldest-searched first (ascending) + var searchRanks = candidates + .OrderBy(c => c.LastSearched ?? DateTime.MinValue) + .Select((c, index) => (c.Id, Rank: n - index)) + .ToDictionary(x => x.Id, x => x.Rank); + + // Rank by add date: newest first (descending) + var ageRanks = candidates + .OrderByDescending(c => c.Added ?? DateTime.MinValue) + .Select((c, index) => (c.Id, Rank: n - index)) + .ToDictionary(x => x.Id, x => x.Rank); + + // Composite weight = average of both ranks (higher = more likely to be selected) + var selected = new List(count); + var pool = candidates + .Select(c => (c.Id, Weight: (searchRanks[c.Id] + ageRanks[c.Id]) / 2.0)) + .ToList(); + + for (int i = 0; i < count && pool.Count > 0; i++) + { + double totalWeight = 0; + for (int j = 0; j < pool.Count; j++) + { + totalWeight += pool[j].Weight; + } + + double target = Random.Shared.NextDouble() * totalWeight; + double cumulative = 0; + int selectedIndex = pool.Count - 1; + + for (int j = 0; j < pool.Count; j++) + { + cumulative += pool[j].Weight; + if (cumulative >= target) + { + selectedIndex = j; + break; + } + } + + selected.Add(pool[selectedIndex].Id); + pool.RemoveAt(selectedIndex); + } + + return selected; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs new file mode 100644 index 00000000..5b1a099f --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestFirstSelector.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items by date added, newest first. +/// Good for quickly finding newly added content. +/// +public sealed class NewestFirstSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + return candidates + .OrderByDescending(c => c.Added ?? DateTime.MinValue) + .Take(count) + .Select(c => c.Id) + .ToList(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs new file mode 100644 index 00000000..705133ff --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/NewestWeightedSelector.cs @@ -0,0 +1,26 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items using rank-based weighted random sampling on add date. +/// Recently added items are ranked higher and more likely to be selected. +/// Good for users who regularly add new content and want it searched quickly. +/// +public sealed class NewestWeightedSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + if (candidates.Count == 0) + { + return []; + } + + count = Math.Min(count, candidates.Count); + + // Sort by Added descending + var ranked = candidates + .OrderByDescending(c => c.Added ?? DateTime.MinValue) + .ToList(); + + return OldestSearchWeightedSelector.WeightedRandomByRank(ranked, count); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs new file mode 100644 index 00000000..d4bbd336 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchFirstSelector.cs @@ -0,0 +1,18 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items that haven't been searched the longest. +/// Items never searched are prioritized first. +/// Provides systematic coverage of the entire library. +/// +public sealed class OldestSearchFirstSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + return candidates + .OrderBy(c => c.LastSearched ?? DateTime.MinValue) + .Take(count) + .Select(c => c.Id) + .ToList(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs new file mode 100644 index 00000000..f96bffd2 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/OldestSearchWeightedSelector.cs @@ -0,0 +1,73 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items using rank-based weighted random sampling on search recency. +/// Items that haven't been searched recently are ranked higher and more likely to be selected. +/// Never-searched items receive the highest rank. +/// +public sealed class OldestSearchWeightedSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + if (candidates.Count == 0) + { + return []; + } + + count = Math.Min(count, candidates.Count); + + // Sort by LastSearched ascending, then oldest searches, so rank 0 = highest priority + var ranked = candidates + .OrderBy(c => c.LastSearched ?? DateTime.MinValue) + .ToList(); + + return WeightedRandomByRank(ranked, count); + } + + /// + /// Performs weighted random selection without replacement using rank-based weights. + /// The item at rank 0 gets the highest weight (N), rank 1 gets (N-1), etc. + /// + internal static List WeightedRandomByRank( + List<(long Id, DateTime? Added, DateTime? LastSearched)> ranked, + int count) + { + int n = ranked.Count; + var selected = new List(count); + + // Build initial weights from rank: highest rank (index 0) gets weight N, lowest gets 1 + var pool = new List<(long Id, double Weight)>(n); + for (int i = 0; i < n; i++) + { + pool.Add((ranked[i].Id, n - i)); + } + + for (int i = 0; i < count && pool.Count > 0; i++) + { + double totalWeight = 0; + for (int j = 0; j < pool.Count; j++) + { + totalWeight += pool[j].Weight; + } + + double target = Random.Shared.NextDouble() * totalWeight; + double cumulative = 0; + int selectedIndex = pool.Count - 1; + + for (int j = 0; j < pool.Count; j++) + { + cumulative += pool[j].Weight; + if (cumulative >= target) + { + selectedIndex = j; + break; + } + } + + selected.Add(pool[selectedIndex].Id); + pool.RemoveAt(selectedIndex); + } + + return selected; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs new file mode 100644 index 00000000..31f05cb1 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/Selectors/RandomSelector.cs @@ -0,0 +1,26 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors; + +/// +/// Selects items randomly using Fisher-Yates shuffle. +/// Simplest strategy with no bias. +/// +public sealed class RandomSelector : IItemSelector +{ + public List Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count) + { + count = Math.Min(count, candidates.Count); + var shuffled = new List<(long Id, DateTime? Added, DateTime? LastSearched)>(candidates); + + // Fisher-Yates shuffle + for (int i = 0; i < count; i++) + { + int j = Random.Shared.Next(i, shuffled.Count); + (shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]); + } + + return shuffled + .Take(count) + .Select(c => c.Id) + .ToList(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Utilities/CronValidationHelper.cs b/code/backend/Cleanuparr.Infrastructure/Utilities/CronValidationHelper.cs index 68ed50ba..ed964545 100644 --- a/code/backend/Cleanuparr.Infrastructure/Utilities/CronValidationHelper.cs +++ b/code/backend/Cleanuparr.Infrastructure/Utilities/CronValidationHelper.cs @@ -53,6 +53,11 @@ public static class CronValidationHelper throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours"); } + if (jobType is JobType.Seeker && triggerValue < Constants.SeekerMinLimit) + { + throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes"); + } + if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit) { throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds"); @@ -65,7 +70,7 @@ public static class CronValidationHelper } catch (Exception ex) { - throw new ValidationException($"Error validating cron expression '{cronExpression}': {ex.Message}"); + throw new ValidationException($"Error validating cron expression '{cronExpression}'", ex); } } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs index f6d8a4ea..1e5cb3a1 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs @@ -1,5 +1,4 @@ using Cleanuparr.Persistence.Models.Configuration.General; -using Cleanuparr.Shared.Helpers; using Shouldly; using Xunit; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; @@ -18,30 +17,6 @@ public sealed class GeneralConfigTests Should.NotThrow(() => config.Validate()); } - [Fact] - public void Validate_WithMinimumSearchDelay_DoesNotThrow() - { - var config = new GeneralConfig - { - HttpTimeout = 100, - SearchDelay = (ushort)Constants.MinSearchDelaySeconds - }; - - Should.NotThrow(() => config.Validate()); - } - - [Fact] - public void Validate_WithAboveMinimumSearchDelay_DoesNotThrow() - { - var config = new GeneralConfig - { - HttpTimeout = 100, - SearchDelay = 300 - }; - - Should.NotThrow(() => config.Validate()); - } - #endregion #region Validate - HttpTimeout Validation @@ -52,7 +27,6 @@ public sealed class GeneralConfigTests var config = new GeneralConfig { HttpTimeout = 0, - SearchDelay = (ushort)Constants.MinSearchDelaySeconds }; var exception = Should.Throw(() => config.Validate()); @@ -69,7 +43,6 @@ public sealed class GeneralConfigTests var config = new GeneralConfig { HttpTimeout = httpTimeout, - SearchDelay = (ushort)Constants.MinSearchDelaySeconds }; Should.NotThrow(() => config.Validate()); @@ -77,52 +50,6 @@ public sealed class GeneralConfigTests #endregion - #region Validate - SearchDelay Validation - - [Fact] - public void Validate_WithBelowMinimumSearchDelay_ThrowsValidationException() - { - var config = new GeneralConfig - { - HttpTimeout = 100, - SearchDelay = (ushort)(Constants.MinSearchDelaySeconds - 1) - }; - - var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); - } - - [Fact] - public void Validate_WithZeroSearchDelay_ThrowsValidationException() - { - var config = new GeneralConfig - { - HttpTimeout = 100, - SearchDelay = 0 - }; - - var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); - } - - [Theory] - [InlineData((ushort)1)] - [InlineData((ushort)30)] - [InlineData((ushort)59)] - public void Validate_WithVariousBelowMinimumSearchDelay_ThrowsValidationException(ushort searchDelay) - { - var config = new GeneralConfig - { - HttpTimeout = 100, - SearchDelay = searchDelay - }; - - var exception = Should.Throw(() => config.Validate()); - exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); - } - - #endregion - #region Validate - Calls LoggingConfig.Validate [Fact] @@ -131,7 +58,6 @@ public sealed class GeneralConfigTests var config = new GeneralConfig { HttpTimeout = 100, - SearchDelay = (ushort)Constants.MinSearchDelaySeconds, Log = new LoggingConfig { RollingSizeMB = 101 // Exceeds max of 100 diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs new file mode 100644 index 00000000..93ffa23c --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Seeker/SeekerConfigTests.cs @@ -0,0 +1,92 @@ +using Cleanuparr.Persistence.Models.Configuration.Seeker; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.Seeker; + +public sealed class SeekerConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDefaultConfig_DoesNotThrow() + { + var config = new SeekerConfig(); + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData((ushort)2)] + [InlineData((ushort)3)] + [InlineData((ushort)4)] + [InlineData((ushort)5)] + [InlineData((ushort)6)] + [InlineData((ushort)10)] + [InlineData((ushort)12)] + [InlineData((ushort)15)] + [InlineData((ushort)20)] + [InlineData((ushort)30)] + public void Validate_WithValidIntervals_DoesNotThrow(ushort interval) + { + var config = new SeekerConfig { SearchInterval = interval }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Invalid Configurations + + [Fact] + public void Validate_WithIntervalBelowMinimum_ThrowsValidationException() + { + var config = new SeekerConfig { SearchInterval = 1 }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("at least"); + } + + [Fact] + public void Validate_WithIntervalAboveMaximum_ThrowsValidationException() + { + var config = new SeekerConfig { SearchInterval = 31 }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("at most"); + } + + [Theory] + [InlineData((ushort)7)] + [InlineData((ushort)8)] + [InlineData((ushort)9)] + [InlineData((ushort)11)] + [InlineData((ushort)13)] + [InlineData((ushort)14)] + public void Validate_WithNonDivisorInterval_ThrowsValidationException(ushort interval) + { + var config = new SeekerConfig { SearchInterval = interval }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("Invalid search interval"); + } + + #endregion + + #region ToCronExpression + + [Theory] + [InlineData((ushort)2, "0 */2 * * * ?")] + [InlineData((ushort)5, "0 */5 * * * ?")] + [InlineData((ushort)10, "0 */10 * * * ?")] + [InlineData((ushort)30, "0 */30 * * * ?")] + public void ToCronExpression_ReturnsCorrectCron(ushort interval, string expectedCron) + { + var config = new SeekerConfig { SearchInterval = interval }; + + config.ToCronExpression().ShouldBe(expectedCron); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 90e78561..c7a98820 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -9,6 +9,7 @@ using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.Notification; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; +using Cleanuparr.Persistence.Models.Configuration.Seeker; using Cleanuparr.Persistence.Models.State; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; @@ -65,6 +66,20 @@ public class DataContext : DbContext public DbSet BlacklistSyncConfigs { get; set; } + public DbSet SeekerConfigs { get; set; } + + public DbSet SeekerInstanceConfigs { get; set; } + + public DbSet SeekerHistory { get; set; } + + public DbSet SearchQueue { get; set; } + + public DbSet CustomFormatScoreEntries { get; set; } + + public DbSet CustomFormatScoreHistory { get; set; } + + public DbSet SeekerCommandTrackers { get; set; } + public DataContext() { } @@ -181,6 +196,9 @@ public class DataContext : DbContext .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(p => p.Name).IsUnique(); + + entity.Property(e => e.CreatedAt).HasConversion(new UtcDateTimeConverter()); + entity.Property(e => e.UpdatedAt).HasConversion(new UtcDateTimeConverter()); }); // Configure PushoverConfig List conversions @@ -197,6 +215,75 @@ public class DataContext : DbContext v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); }); + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(s => s.ArrInstanceId).IsUnique(); + + entity.Property(s => s.LastProcessedAt).HasConversion(new UtcDateTimeConverter()); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.ItemType, s.SeasonNumber, s.CycleId }).IsUnique(); + + entity.Property(s => s.LastSearchedAt).HasConversion(new UtcDateTimeConverter()); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.Property(s => s.CreatedAt).HasConversion(new UtcDateTimeConverter()); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.Property(s => s.CreatedAt).HasConversion(new UtcDateTimeConverter()); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId }).IsUnique(); + + entity.Property(s => s.LastSyncedAt).HasConversion(new UtcDateTimeConverter()); + }); + + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.ArrInstance) + .WithMany() + .HasForeignKey(s => s.ArrInstanceId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId }); + entity.HasIndex(s => s.RecordedAt); + + entity.Property(s => s.RecordedAt).HasConversion(new UtcDateTimeConverter()); + }); + // Configure BlacklistSyncState relationships and indexes modelBuilder.Entity(entity => { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.Designer.cs new file mode 100644 index 00000000..6f969353 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.Designer.cs @@ -0,0 +1,1736 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260324200753_AddSeeker")] + partial class AddSeeker + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.PrimitiveCollection("UnlinkedIgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dirs"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.HasKey("Id") + .HasName("pk_seeding_rules"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_seeding_rules_download_cleaner_config_id"); + + b.ToTable("seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.cs new file mode 100644 index 00000000..4fb58f1e --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.cs @@ -0,0 +1,293 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddSeeker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "on_search_triggered", + table: "notification_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "custom_format_score_entries", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + external_item_id = table.Column(type: "INTEGER", nullable: false), + episode_id = table.Column(type: "INTEGER", nullable: false), + item_type = table.Column(type: "TEXT", nullable: false), + title = table.Column(type: "TEXT", nullable: false), + file_id = table.Column(type: "INTEGER", nullable: false), + current_score = table.Column(type: "INTEGER", nullable: false), + cutoff_score = table.Column(type: "INTEGER", nullable: false), + quality_profile_name = table.Column(type: "TEXT", nullable: false), + last_synced_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_custom_format_score_entries", x => x.id); + table.ForeignKey( + name: "fk_custom_format_score_entries_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "custom_format_score_history", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + external_item_id = table.Column(type: "INTEGER", nullable: false), + episode_id = table.Column(type: "INTEGER", nullable: false), + item_type = table.Column(type: "TEXT", nullable: false), + title = table.Column(type: "TEXT", nullable: false), + score = table.Column(type: "INTEGER", nullable: false), + cutoff_score = table.Column(type: "INTEGER", nullable: false), + recorded_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_custom_format_score_history", x => x.id); + table.ForeignKey( + name: "fk_custom_format_score_history_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "search_queue", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + item_id = table.Column(type: "INTEGER", nullable: false), + series_id = table.Column(type: "INTEGER", nullable: true), + search_type = table.Column(type: "TEXT", nullable: true), + title = table.Column(type: "TEXT", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_search_queue", x => x.id); + table.ForeignKey( + name: "fk_search_queue_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "seeker_command_trackers", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + command_id = table.Column(type: "INTEGER", nullable: false), + event_id = table.Column(type: "TEXT", nullable: false), + external_item_id = table.Column(type: "INTEGER", nullable: false), + item_title = table.Column(type: "TEXT", nullable: false), + item_type = table.Column(type: "TEXT", nullable: false), + season_number = table.Column(type: "INTEGER", nullable: false), + created_at = table.Column(type: "TEXT", nullable: false), + status = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_command_trackers", x => x.id); + table.ForeignKey( + name: "fk_seeker_command_trackers_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "seeker_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + search_enabled = table.Column(type: "INTEGER", nullable: false), + search_interval = table.Column(type: "INTEGER", nullable: false), + proactive_search_enabled = table.Column(type: "INTEGER", nullable: false), + selection_strategy = table.Column(type: "TEXT", nullable: false), + monitored_only = table.Column(type: "INTEGER", nullable: false), + use_cutoff = table.Column(type: "INTEGER", nullable: false), + use_custom_format_score = table.Column(type: "INTEGER", nullable: false), + use_round_robin = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_configs", x => x.id); + }); + + // Migrate old data + migrationBuilder.InsertData( + table: "seeker_configs", + columns: new[] { "id", "search_enabled", "search_interval", "proactive_search_enabled", "selection_strategy", "monitored_only", "use_cutoff", "use_custom_format_score", "use_round_robin" }, + values: new object[] { Guid.NewGuid(), true, 10, false, "balancedweighted", true, true, true, true }); + + migrationBuilder.Sql(@" + UPDATE seeker_configs SET search_enabled = ( + SELECT COALESCE(g.search_enabled, 1) FROM general_configs g LIMIT 1 + )"); + + migrationBuilder.CreateTable( + name: "seeker_history", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + external_item_id = table.Column(type: "INTEGER", nullable: false), + item_type = table.Column(type: "TEXT", nullable: false), + season_number = table.Column(type: "INTEGER", nullable: false), + cycle_id = table.Column(type: "TEXT", nullable: false), + last_searched_at = table.Column(type: "TEXT", nullable: false), + item_title = table.Column(type: "TEXT", nullable: false), + search_count = table.Column(type: "INTEGER", nullable: false), + is_dry_run = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_history", x => x.id); + table.ForeignKey( + name: "fk_seeker_history_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "seeker_instance_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + skip_tags = table.Column(type: "TEXT", nullable: false), + last_processed_at = table.Column(type: "TEXT", nullable: true), + current_cycle_id = table.Column(type: "TEXT", nullable: false), + total_eligible_items = table.Column(type: "INTEGER", nullable: false), + active_download_limit = table.Column(type: "INTEGER", nullable: false), + min_cycle_time_days = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_instance_configs", x => x.id); + table.ForeignKey( + name: "fk_seeker_instance_configs_arr_instances_arr_instance_id", + column: x => x.arr_instance_id, + principalTable: "arr_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.DropColumn( + name: "search_delay", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "search_enabled", + table: "general_configs"); + + migrationBuilder.CreateIndex( + name: "ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id", + table: "custom_format_score_entries", + columns: new[] { "arr_instance_id", "external_item_id", "episode_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id", + table: "custom_format_score_history", + columns: new[] { "arr_instance_id", "external_item_id", "episode_id" }); + + migrationBuilder.CreateIndex( + name: "ix_custom_format_score_history_recorded_at", + table: "custom_format_score_history", + column: "recorded_at"); + + migrationBuilder.CreateIndex( + name: "ix_search_queue_arr_instance_id", + table: "search_queue", + column: "arr_instance_id"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_command_trackers_arr_instance_id", + table: "seeker_command_trackers", + column: "arr_instance_id"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id", + table: "seeker_history", + columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "cycle_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_seeker_instance_configs_arr_instance_id", + table: "seeker_instance_configs", + column: "arr_instance_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "custom_format_score_entries"); + + migrationBuilder.DropTable( + name: "custom_format_score_history"); + + migrationBuilder.DropTable( + name: "search_queue"); + + migrationBuilder.DropTable( + name: "seeker_command_trackers"); + + migrationBuilder.DropTable( + name: "seeker_configs"); + + migrationBuilder.DropTable( + name: "seeker_history"); + + migrationBuilder.DropTable( + name: "seeker_instance_configs"); + + migrationBuilder.DropColumn( + name: "on_search_triggered", + table: "notification_configs"); + + migrationBuilder.AddColumn( + name: "search_delay", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.AddColumn( + name: "search_enabled", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.Designer.cs new file mode 100644 index 00000000..d440c52b --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.Designer.cs @@ -0,0 +1,1740 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260325201158_AddPostReleaseGraceHours")] + partial class AddPostReleaseGraceHours + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.PrimitiveCollection("UnlinkedIgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dirs"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.HasKey("Id") + .HasName("pk_seeding_rules"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_seeding_rules_download_cleaner_config_id"); + + b.ToTable("seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.cs new file mode 100644 index 00000000..2d6516dc --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260325201158_AddPostReleaseGraceHours.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddPostReleaseGraceHours : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "post_release_grace_hours", + table: "seeker_configs", + type: "INTEGER", + nullable: false, + defaultValue: 6); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "post_release_grace_hours", + table: "seeker_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.Designer.cs new file mode 100644 index 00000000..eac7ae4e --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.Designer.cs @@ -0,0 +1,1744 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260326195141_AddIsMonitoredToCfScoreEntry")] + partial class AddIsMonitoredToCfScoreEntry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.PrimitiveCollection("UnlinkedIgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dirs"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.HasKey("Id") + .HasName("pk_seeding_rules"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_seeding_rules_download_cleaner_config_id"); + + b.ToTable("seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.SeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeding_rules_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.cs new file mode 100644 index 00000000..a58ed25e --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260326195141_AddIsMonitoredToCfScoreEntry.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddIsMonitoredToCfScoreEntry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "is_monitored", + table: "custom_format_score_entries", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "is_monitored", + table: "custom_format_score_entries"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index bd5101e4..ea7e9301 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -303,14 +303,6 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("ignored_downloads"); - b.Property("SearchDelay") - .HasColumnType("INTEGER") - .HasColumnName("search_delay"); - - b.Property("SearchEnabled") - .HasColumnType("INTEGER") - .HasColumnName("search_enabled"); - b.Property("StatusCheckEnabled") .HasColumnType("INTEGER") .HasColumnName("status_check_enabled"); @@ -703,6 +695,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("INTEGER") .HasColumnName("on_queue_item_deleted"); + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + b.Property("OnSlowStrike") .HasColumnType("INTEGER") .HasColumnName("on_slow_strike"); @@ -1090,6 +1086,106 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("stall_rules", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => { b.Property("Id") @@ -1122,6 +1218,274 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("blacklist_sync_history", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => { b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") @@ -1254,6 +1618,18 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("QueueCleanerConfig"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => { b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") @@ -1266,6 +1642,66 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("DownloadClient"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => { b.Navigation("Instances"); diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.Designer.cs new file mode 100644 index 00000000..58de6f28 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.Designer.cs @@ -0,0 +1,405 @@ +// +using System; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + [DbContext(typeof(EventsContext))] + [Migration("20260324200744_AddSeeker")] + partial class AddSeeker + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("Data") + .HasColumnType("TEXT") + .HasColumnName("data"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("SearchStatus") + .HasColumnType("TEXT") + .HasColumnName("search_status"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeId") + .HasColumnType("TEXT") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("TEXT") + .HasColumnName("tracking_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("CycleId") + .HasDatabaseName("ix_events_cycle_id"); + + b.HasIndex("DownloadClientType") + .HasDatabaseName("ix_events_download_client_type"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("InstanceType") + .HasDatabaseName("ix_events_instance_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + b.HasIndex("Message") + .HasDatabaseName("ix_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_events_severity"); + + b.HasIndex("StrikeId") + .HasDatabaseName("ix_events_strike_id"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_events_timestamp"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Data") + .HasColumnType("TEXT") + .HasColumnName("data"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("Timestamp") + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.HasKey("Id") + .HasName("pk_manual_events"); + + b.HasIndex("InstanceType") + .HasDatabaseName("ix_manual_events_instance_type"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_manual_events_job_run_id"); + + b.HasIndex("Message") + .HasDatabaseName("ix_manual_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_manual_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_manual_events_timestamp"); + + b.ToTable("manual_events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("INTEGER") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("INTEGER") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("INTEGER") + .HasColumnName("is_returning"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_download_items"); + + b.HasIndex("DownloadId") + .IsUnique() + .HasDatabaseName("ix_download_items_download_id"); + + b.ToTable("download_items", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_job_runs"); + + b.HasIndex("StartedAt") + .IsDescending() + .HasDatabaseName("ix_job_runs_started_at"); + + b.HasIndex("Type") + .HasDatabaseName("ix_job_runs_type"); + + b.ToTable("job_runs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("TEXT") + .HasColumnName("download_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("INTEGER") + .HasColumnName("last_downloaded_bytes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_strikes"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_strikes_created_at"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_strikes_job_run_id"); + + b.HasIndex("DownloadItemId", "Type") + .HasDatabaseName("ix_strikes_download_item_id_type"); + + b.ToTable("strikes", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Events") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_events_job_runs_job_run_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike") + .WithMany() + .HasForeignKey("StrikeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_strikes_strike_id"); + + b.Navigation("JobRun"); + + b.Navigation("Strike"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("ManualEvents") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_manual_events_job_runs_job_run_id"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem") + .WithMany("Strikes") + .HasForeignKey("DownloadItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_download_items_download_item_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Strikes") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_job_runs_job_run_id"); + + b.Navigation("DownloadItem"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Navigation("Strikes"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Navigation("Events"); + + b.Navigation("ManualEvents"); + + b.Navigation("Strikes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.cs new file mode 100644 index 00000000..bee9f339 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260324200744_AddSeeker.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddSeeker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "completed_at", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "cycle_id", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "search_status", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_events_cycle_id", + table: "events", + column: "cycle_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_events_cycle_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "completed_at", + table: "events"); + + migrationBuilder.DropColumn( + name: "cycle_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "search_status", + table: "events"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs index 704ea257..42b97625 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs @@ -24,6 +24,14 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + b.Property("Data") .HasColumnType("TEXT") .HasColumnName("data"); @@ -65,6 +73,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("message"); + b.Property("SearchStatus") + .HasColumnType("TEXT") + .HasColumnName("search_status"); + b.Property("Severity") .IsRequired() .HasColumnType("TEXT") @@ -85,6 +97,9 @@ namespace Cleanuparr.Persistence.Migrations.Events b.HasKey("Id") .HasName("pk_events"); + b.HasIndex("CycleId") + .HasDatabaseName("ix_events_cycle_id"); + b.HasIndex("DownloadClientType") .HasDatabaseName("ix_events_download_client_type"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs index 9cc966cd..1708dd96 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs @@ -22,9 +22,6 @@ public sealed record GeneralConfig : IConfig public CertificateValidationType HttpCertificateValidation { get; set; } = CertificateValidationType.Enabled; - public bool SearchEnabled { get; set; } = true; - - public ushort SearchDelay { get; set; } = Constants.DefaultSearchDelaySeconds; public bool StatusCheckEnabled { get; set; } = true; @@ -45,11 +42,6 @@ public sealed record GeneralConfig : IConfig throw new ValidationException($"{nameof(HttpTimeout)} must be greater than 0"); } - if (SearchDelay < Constants.MinSearchDelaySeconds) - { - throw new ValidationException($"{nameof(SearchDelay)} must be at least {Constants.MinSearchDelaySeconds} seconds"); - } - Log.Validate(); Auth.Validate(); } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs index efdb614c..76eaa7a2 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs @@ -30,7 +30,9 @@ public sealed record NotificationConfig public bool OnDownloadCleaned { get; init; } public bool OnCategoryChanged { get; init; } - + + public bool OnSearchTriggered { get; init; } + public DateTime CreatedAt { get; init; } = DateTime.UtcNow; public DateTime UpdatedAt { get; init; } = DateTime.UtcNow; @@ -63,11 +65,12 @@ public sealed record NotificationConfig }; [NotMapped] - public bool HasAnyEventEnabled => + public bool HasAnyEventEnabled => OnFailedImportStrike || OnStalledStrike || OnSlowStrike || OnQueueItemDeleted || OnDownloadCleaned || - OnCategoryChanged; + OnCategoryChanged || + OnSearchTriggered; } diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs new file mode 100644 index 00000000..e2e5a03f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerConfig.cs @@ -0,0 +1,96 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Shared.Helpers; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Models.Configuration.Seeker; + +/// +/// The Seeker job is always running; only its behavior is configurable. +/// +public sealed record SeekerConfig : IConfig +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Master toggle for all searching (reactive and proactive). + /// When disabled, no searches are triggered at all. + /// + public bool SearchEnabled { get; set; } = true; + + /// + /// Interval in minutes between Seeker runs. Controls how frequently searches are triggered. + /// Valid values: 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 (must divide 60 evenly for cron compatibility). + /// + public ushort SearchInterval { get; set; } = Constants.DefaultSearchIntervalMinutes; + + /// + /// Enables proactive searching for missing items and quality upgrades. + /// When disabled, only reactive searches (replacement after removal) are performed. + /// + public bool ProactiveSearchEnabled { get; set; } + + /// + /// Strategy used to select which items to search during proactive searches + /// + public SelectionStrategy SelectionStrategy { get; set; } = SelectionStrategy.BalancedWeighted; + + /// + /// Only search monitored items during proactive searches + /// + public bool MonitoredOnly { get; set; } = true; + + /// + /// Skip items that already meet their quality cutoff during proactive searches + /// + public bool UseCutoff { get; set; } + + /// + /// Search items whose custom format score is below the quality profile's cutoff format score + /// + public bool UseCustomFormatScore { get; set; } + + /// + /// Process one instance per run to spread indexer load during proactive searches + /// + public bool UseRoundRobin { get; set; } = true; + + /// + /// Hours to wait after content is released before searching. + /// Gives indexers time to process new releases. 0 = disabled. + /// + public int PostReleaseGraceHours { get; set; } = 6; + + public void Validate() + { + if (SearchInterval < Constants.MinSearchIntervalMinutes) + { + throw new ValidationException( + $"{nameof(SearchInterval)} must be at least {Constants.MinSearchIntervalMinutes} minute(s)"); + } + + if (SearchInterval > Constants.MaxSearchIntervalMinutes) + { + throw new ValidationException( + $"{nameof(SearchInterval)} must be at most {Constants.MaxSearchIntervalMinutes} minutes"); + } + + if (!new List { 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 }.Contains(SearchInterval)) + { + throw new ValidationException($"Invalid search interval {SearchInterval}"); + } + + if (PostReleaseGraceHours is < 0 or > 72) + { + throw new ValidationException($"{nameof(PostReleaseGraceHours)} must be between 0 and 72"); + } + } + + /// + /// Generates the internal cron expression from the SearchInterval. + /// + public string ToCronExpression() => $"0 */{SearchInterval} * * * ?"; +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs new file mode 100644 index 00000000..e8cce554 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Seeker/SeekerInstanceConfig.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.Configuration.Seeker; + +/// +/// Per-instance configuration for the Seeker job. +/// Links to an ArrInstance with cascade delete. +/// +public sealed record SeekerInstanceConfig +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance this config belongs to + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// Whether this instance is enabled for Seeker searches + /// + public bool Enabled { get; set; } = true; + + /// + /// Arr tag IDs to exclude from search + /// + public List SkipTags { get; set; } = []; + + /// + /// Timestamp of when this instance was last processed (for round-robin scheduling) + /// + public DateTime? LastProcessedAt { get; set; } + + /// + /// The current cycle ID. All searches in the same cycle share this ID. + /// When all eligible items have been searched, a new ID is generated to start a fresh cycle. + /// + public Guid CurrentCycleId { get; set; } = Guid.NewGuid(); + + /// + /// Total number of eligible items in the library for this instance. + /// Updated each time the Seeker processes the instance. + /// + public int TotalEligibleItems { get; set; } + + /// + /// Skip proactive search cycles when the number of actively downloading items + /// (SizeLeft > 0) in the arr queue is at or above this threshold. 0 = disabled. + /// + public int ActiveDownloadLimit { get; set; } = 3; + + /// + /// Minimum number of days a cycle must span before a new one can start. + /// If a cycle completes faster, no searches are triggered until this time has elapsed. + /// + public int MinCycleTimeDays { get; set; } = 7; +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs index fd8f509a..ca4a0fcc 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs @@ -17,6 +17,7 @@ namespace Cleanuparr.Persistence.Models.Events; [Index(nameof(JobRunId))] [Index(nameof(InstanceType))] [Index(nameof(DownloadClientType))] +[Index(nameof(CycleId))] public class AppEvent : IEvent { [Key] @@ -74,5 +75,20 @@ public class AppEvent : IEvent [MaxLength(200)] public string? DownloadClientName { get; set; } + /// + /// Status of the search command (only set for SearchTriggered events) + /// + public SearchCommandStatus? SearchStatus { get; set; } + + /// + /// When the search command completed (only set for SearchTriggered events) + /// + public DateTime? CompletedAt { get; set; } + + /// + /// The Seeker cycle ID associated with this event (only set for SearchTriggered events) + /// + public Guid? CycleId { get; set; } + public bool IsDryRun { get; set; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs new file mode 100644 index 00000000..9331f628 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs @@ -0,0 +1,77 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Current custom format score state for a library item. +/// Updated periodically by the CustomFormatScoreSyncer job. +/// +public sealed record CustomFormatScoreEntry +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// The external item ID (Radarr movieId or Sonarr seriesId) + /// + public long ExternalItemId { get; set; } + + /// + /// For Sonarr episodes, the episode ID. 0 for Radarr movies. + /// + public long EpisodeId { get; set; } + + /// + /// The type of arr instance (Radarr/Sonarr) + /// + public InstanceType ItemType { get; set; } + + /// + /// The item title for display purposes + /// + public string Title { get; set; } = string.Empty; + + /// + /// The movie file ID or episode file ID in the arr app + /// + public long FileId { get; set; } + + /// + /// The current custom format score of the file + /// + public int CurrentScore { get; set; } + + /// + /// The cutoff format score from the quality profile + /// + public int CutoffScore { get; set; } + + /// + /// The quality profile name for display purposes + /// + public string QualityProfileName { get; set; } = string.Empty; + + /// + /// Whether the item is currently monitored in the arr app + /// + public bool IsMonitored { get; set; } + + /// + /// When this entry was last synced from the arr API + /// + public DateTime LastSyncedAt { get; set; } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreHistory.cs b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreHistory.cs new file mode 100644 index 00000000..31123bfe --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreHistory.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Historical record of custom format score changes. +/// Only written when a score value actually changes (deduplication). +/// +public sealed record CustomFormatScoreHistory +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// The external item ID (Radarr movieId or Sonarr seriesId) + /// + public long ExternalItemId { get; set; } + + /// + /// For Sonarr episodes, the episode ID. 0 for Radarr movies. + /// + public long EpisodeId { get; set; } + + /// + /// The type of arr instance (Radarr/Sonarr) + /// + public InstanceType ItemType { get; set; } + + /// + /// The item title for display purposes + /// + public string Title { get; set; } = string.Empty; + + /// + /// The custom format score at the time of recording + /// + public int Score { get; set; } + + /// + /// The cutoff format score from the quality profile at the time of recording + /// + public int CutoffScore { get; set; } + + /// + /// When this score was recorded + /// + public DateTime RecordedAt { get; set; } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SearchQueueItem.cs b/code/backend/Cleanuparr.Persistence/Models/State/SearchQueueItem.cs new file mode 100644 index 00000000..63d67faf --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/SearchQueueItem.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Represents a pending reactive search request queued after a download removal. +/// The Seeker processes these items with priority before proactive searches. +/// +public sealed record SearchQueueItem +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance this search targets + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// The item ID to search for (movieId, episodeId, albumId, etc.) + /// + public long ItemId { get; set; } + + /// + /// For Sonarr/Whisparr: the series ID when searching at episode/season level + /// + public long? SeriesId { get; set; } + + /// + /// For Sonarr/Whisparr: the search type ("Episode" or "Season") + /// + public string? SearchType { get; set; } + + /// + /// Display title for logging and event publishing + /// + public string Title { get; set; } = ""; + + /// + /// When this search request was created + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs new file mode 100644 index 00000000..d1053f53 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Tracks arr command IDs returned from search requests so the SeekerCommandMonitor +/// can poll for completion status and inspect the download queue afterward. +/// +public sealed record SeekerCommandTracker +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance this command was sent to + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// The command ID returned by the arr API + /// + public long CommandId { get; set; } + + /// + /// The AppEvent ID to update when the command completes + /// + public Guid EventId { get; set; } + + /// + /// The external item ID that was searched (movieId or seriesId) + /// + public long ExternalItemId { get; set; } + + /// + /// Display name of the item that was searched + /// + public string ItemTitle { get; set; } = string.Empty; + + /// + /// The type of arr instance (Radarr, Sonarr, etc.) + /// + public InstanceType ItemType { get; set; } + + /// + /// For Sonarr season-level searches, the season number that was searched. + /// 0 for Radarr or when not applicable. + /// + public int SeasonNumber { get; set; } + + /// + /// When this tracker entry was created + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Current status of the arr command + /// + public SearchCommandStatus Status { get; set; } = SearchCommandStatus.Pending; +} diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs new file mode 100644 index 00000000..45f08833 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Arr; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// Tracks the last time each media item was searched by the Seeker job. +/// Used by selection strategies to prioritize items that haven't been searched recently. +/// +public sealed record SeekerHistory +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Foreign key to the arr instance this history belongs to + /// + public Guid ArrInstanceId { get; set; } + + /// + /// Navigation property to the associated arr instance + /// + public ArrInstance ArrInstance { get; set; } = null!; + + /// + /// The external item ID in the arr application (e.g., Radarr movieId or Sonarr seriesId) + /// + public long ExternalItemId { get; set; } + + /// + /// The type of arr instance this item belongs to + /// + public InstanceType ItemType { get; set; } + + /// + /// For Sonarr season-level searches, the season number that was searched + /// + public int SeasonNumber { get; set; } + + /// + /// The cycle ID. All searches in the same cycle share a CycleId. + /// When all items have been searched, a new CycleId is generated to start a fresh cycle. + /// + public Guid CycleId { get; set; } + + /// + /// When this item was last searched + /// + public DateTime LastSearchedAt { get; set; } + + /// + /// Display name of the item (movie title, series name, etc.) + /// + public string ItemTitle { get; set; } = string.Empty; + + /// + /// Running count of how many times this item has been searched + /// + public int SearchCount { get; set; } = 1; + + /// + /// Whether this history entry was created during a dry run + /// + public bool IsDryRun { get; set; } +} diff --git a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs index 5f6c7f13..24fc917a 100644 --- a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs +++ b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs @@ -6,16 +6,20 @@ public static class Constants { public static readonly TimeSpan TriggerMaxLimit = TimeSpan.FromHours(6); public static readonly TimeSpan TriggerMinLimit = TimeSpan.FromSeconds(30); + public static readonly TimeSpan SeekerMinLimit = TimeSpan.FromMinutes(1); public const string HttpClientWithRetryName = "retry"; - + public static readonly MemoryCacheEntryOptions DefaultCacheEntryOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(10) }; - - public const int DefaultSearchDelaySeconds = 120; - public const int MinSearchDelaySeconds = 60; + + public const ushort DefaultSearchIntervalMinutes = 3; + public const ushort MinSearchIntervalMinutes = 2; + public const ushort MaxSearchIntervalMinutes = 30; public const string LogoUrl = "https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/48.png"; + + public const string CustomFormatScoreSyncerCron = "0 0/30 * * * ?"; } \ No newline at end of file diff --git a/code/frontend/angular.json b/code/frontend/angular.json index 05a395c4..0f1c1e28 100644 --- a/code/frontend/angular.json +++ b/code/frontend/angular.json @@ -71,8 +71,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "8kB", - "maximumError": "12kB" + "maximumWarning": "10kB", + "maximumError": "30kB" } ], "outputHashing": "all" diff --git a/code/frontend/src/app/app.config.ts b/code/frontend/src/app/app.config.ts index aa141907..1f4d9468 100644 --- a/code/frontend/src/app/app.config.ts +++ b/code/frontend/src/app/app.config.ts @@ -34,6 +34,8 @@ import { tablerEye, tablerEyeOff, tablerArrowDown, + tablerArrowRight, + tablerChartBar, tablerCircleX, tablerInfoCircle, tablerCode, @@ -45,6 +47,10 @@ import { tablerFileExport, tablerUser, tablerLogout, + tablerSearch, + tablerChartDots, + tablerHistory, + tablerGripVertical, } from '@ng-icons/tabler-icons'; import { routes } from './app.routes'; @@ -89,6 +95,8 @@ export const appConfig: ApplicationConfig = { tablerEye, tablerEyeOff, tablerArrowDown, + tablerArrowRight, + tablerChartBar, tablerCircleX, tablerInfoCircle, tablerCode, @@ -100,6 +108,10 @@ export const appConfig: ApplicationConfig = { tablerFileExport, tablerUser, tablerLogout, + tablerSearch, + tablerChartDots, + tablerHistory, + tablerGripVertical, }), ], }; diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index d67a6404..65e55806 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -37,6 +37,15 @@ export const routes: Routes = [ (m) => m.StrikesComponent, ), }, + { + path: 'seeker-stats', + loadComponent: () => + import('@features/seeker-stats/seeker-stats.component').then( + (m) => m.SeekerStatsComponent, + ), + }, + { path: 'cf-scores', redirectTo: 'seeker-stats', pathMatch: 'full' }, + { path: 'search-stats', redirectTo: 'seeker-stats', pathMatch: 'full' }, { path: 'settings', children: [ @@ -80,6 +89,14 @@ export const routes: Routes = [ ).then((m) => m.BlacklistSyncComponent), canDeactivate: [pendingChangesGuard], }, + { + path: 'seeker', + loadComponent: () => + import( + '@features/settings/seeker/seeker.component' + ).then((m) => m.SeekerComponent), + canDeactivate: [pendingChangesGuard], + }, { path: 'arr/:type', loadComponent: () => diff --git a/code/frontend/src/app/core/api/cf-score.api.ts b/code/frontend/src/app/core/api/cf-score.api.ts new file mode 100644 index 00000000..92897266 --- /dev/null +++ b/code/frontend/src/app/core/api/cf-score.api.ts @@ -0,0 +1,121 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface CfScoreStats { + totalTracked: number; + belowCutoff: number; + atOrAboveCutoff: number; + monitored: number; + unmonitored: number; + recentUpgrades: number; + perInstanceStats: InstanceCfScoreStat[]; +} + +export interface InstanceCfScoreStat { + instanceId: string; + instanceName: string; + instanceType: string; + totalTracked: number; + belowCutoff: number; + atOrAboveCutoff: number; + monitored: number; + unmonitored: number; + recentUpgrades: number; +} + +export interface CfScoreUpgrade { + arrInstanceId: string; + externalItemId: number; + episodeId: number; + itemType: string; + title: string; + previousScore: number; + newScore: number; + cutoffScore: number; + upgradedAt: string; +} + +export interface CfScoreUpgradesResponse { + items: CfScoreUpgrade[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +export interface CfScoreEntry { + id: string; + arrInstanceId: string; + externalItemId: number; + episodeId: number; + itemType: string; + title: string; + fileId: number; + currentScore: number; + cutoffScore: number; + qualityProfileName: string; + isBelowCutoff: boolean; + isMonitored: boolean; + lastSyncedAt: string; +} + +export interface CfScoreEntriesResponse { + items: CfScoreEntry[]; + page: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +export interface CfScoreHistoryEntry { + score: number; + cutoffScore: number; + recordedAt: string; +} + +export interface CfScoreHistoryResponse { + entries: CfScoreHistoryEntry[]; +} + +export interface CfScoreInstance { + id: string; + name: string; + itemType: string; +} + +@Injectable({ providedIn: 'root' }) +export class CfScoreApi { + private http = inject(HttpClient); + + getStats(): Observable { + return this.http.get('/api/seeker/cf-scores/stats'); + } + + getRecentUpgrades(page = 1, pageSize = 5, instanceId?: string, days?: number): Observable { + const params: Record = { page, pageSize }; + if (instanceId) params['instanceId'] = instanceId; + if (days !== undefined) params['days'] = days; + return this.http.get('/api/seeker/cf-scores/upgrades', { params }); + } + + getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean): Observable { + const params: Record = { page, pageSize }; + if (search) params['search'] = search; + if (instanceId) params['instanceId'] = instanceId; + if (sortBy) params['sortBy'] = sortBy; + if (hideMet) params['hideMet'] = true; + return this.http.get('/api/seeker/cf-scores', { params }); + } + + getInstances(): Observable<{ instances: CfScoreInstance[] }> { + return this.http.get<{ instances: CfScoreInstance[] }>('/api/seeker/cf-scores/instances'); + } + + getItemHistory(instanceId: string, itemId: number, episodeId = 0): Observable { + return this.http.get( + `/api/seeker/cf-scores/${instanceId}/${itemId}/history`, + { params: { episodeId } }, + ); + } +} diff --git a/code/frontend/src/app/core/api/search-stats.api.ts b/code/frontend/src/app/core/api/search-stats.api.ts new file mode 100644 index 00000000..79bf2895 --- /dev/null +++ b/code/frontend/src/app/core/api/search-stats.api.ts @@ -0,0 +1,22 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import type { SearchStatsSummary, SearchEvent } from '@core/models/search-stats.models'; +import type { PaginatedResult } from '@core/models/pagination.model'; + +@Injectable({ providedIn: 'root' }) +export class SearchStatsApi { + private http = inject(HttpClient); + + getSummary(): Observable { + return this.http.get('/api/seeker/search-stats/summary'); + } + + getEvents(page = 1, pageSize = 50, instanceId?: string, cycleId?: string, search?: string): Observable> { + const params: Record = { page, pageSize }; + if (instanceId) params['instanceId'] = instanceId; + if (cycleId) params['cycleId'] = cycleId; + if (search) params['search'] = search; + return this.http.get>('/api/seeker/search-stats/events', { params }); + } +} diff --git a/code/frontend/src/app/core/api/seeker.api.ts b/code/frontend/src/app/core/api/seeker.api.ts new file mode 100644 index 00000000..33a2ee6c --- /dev/null +++ b/code/frontend/src/app/core/api/seeker.api.ts @@ -0,0 +1,17 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { SeekerConfig, UpdateSeekerConfig } from '@shared/models/seeker-config.model'; + +@Injectable({ providedIn: 'root' }) +export class SeekerApi { + private http = inject(HttpClient); + + getConfig(): Observable { + return this.http.get('/api/configuration/seeker'); + } + + updateConfig(config: UpdateSeekerConfig): Observable { + return this.http.put('/api/configuration/seeker', config); + } +} diff --git a/code/frontend/src/app/core/models/index.ts b/code/frontend/src/app/core/models/index.ts index 47fa1f53..7afb8d90 100644 --- a/code/frontend/src/app/core/models/index.ts +++ b/code/frontend/src/app/core/models/index.ts @@ -3,3 +3,4 @@ export * from './job.models'; export * from './signalr.models'; export * from './app-status.model'; export * from './pagination.model'; +export * from './search-stats.models'; diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts new file mode 100644 index 00000000..63d89def --- /dev/null +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -0,0 +1,43 @@ +export interface InstanceSearchStat { + instanceId: string; + instanceName: string; + instanceType: string; + itemsTracked: number; + totalSearchCount: number; + lastSearchedAt: string | null; + lastProcessedAt: string | null; + currentCycleId: string | null; + cycleItemsSearched: number; + cycleItemsTotal: number; + cycleStartedAt: string | null; +} + +export interface SearchStatsSummary { + totalSearchesAllTime: number; + searchesLast7Days: number; + searchesLast30Days: number; + uniqueItemsSearched: number; + pendingReplacementSearches: number; + enabledInstances: number; + perInstanceStats: InstanceSearchStat[]; +} + +export enum SeekerSearchType { + Proactive = 'Proactive', + Replacement = 'Replacement', +} + +export interface SearchEvent { + id: string; + timestamp: string; + instanceName: string; + instanceType: string | null; + itemCount: number; + items: string[]; + searchType: SeekerSearchType; + searchStatus: string | null; + completedAt: string | null; + grabbedItems: unknown[] | null; + cycleId: string | null; + isDryRun: boolean; +} diff --git a/code/frontend/src/app/core/realtime/app-hub.service.ts b/code/frontend/src/app/core/realtime/app-hub.service.ts index 30d4ed4e..f28a2b6e 100644 --- a/code/frontend/src/app/core/realtime/app-hub.service.ts +++ b/code/frontend/src/app/core/realtime/app-hub.service.ts @@ -26,6 +26,8 @@ export class AppHubService extends HubService { private readonly _strikes = signal([]); private readonly _jobs = signal([]); private readonly _appStatus = signal(null); + private readonly _cfScoresVersion = signal(0); + private readonly _searchStatsVersion = signal(0); readonly logs = this._logs.asReadonly(); readonly events = this._events.asReadonly(); @@ -33,6 +35,8 @@ export class AppHubService extends HubService { readonly strikes = this._strikes.asReadonly(); readonly jobs = this._jobs.asReadonly(); readonly appStatus = this._appStatus.asReadonly(); + readonly cfScoresVersion = this._cfScoresVersion.asReadonly(); + readonly searchStatsVersion = this._searchStatsVersion.asReadonly(); protected registerHandlers(connection: signalR.HubConnection): void { // Single log entry @@ -108,6 +112,16 @@ export class AppHubService extends HubService { connection.on('AppStatusUpdated', (status: AppStatus) => { this._appStatus.set(status); }); + + // CF scores refresh + connection.on('CfScoresUpdated', () => { + this._cfScoresVersion.update(v => v + 1); + }); + + // Search stats refresh + connection.on('SearchStatsUpdated', () => { + this._searchStatsVersion.update(v => v + 1); + }); } protected override onConnected(): void { diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index a59ccd51..8305c318 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -48,8 +48,6 @@ export class DocumentationService { 'httpMaxRetries': 'http-max-retries', 'httpTimeout': 'http-timeout', 'httpCertificateValidation': 'http-certificate-validation', - 'searchEnabled': 'search-enabled', - 'searchDelay': 'search-delay', 'statusCheckEnabled': 'status-check', 'log.level': 'log-level', 'log.rollingSizeMB': 'rolling-size-mb', @@ -191,6 +189,23 @@ export class DocumentationService { 'applicationToken': 'application-token', 'priority': 'priority', }, + 'seeker': { + 'searchEnabled': 'search-enabled', + 'searchInterval': 'search-interval', + 'replacementSearchEnabled': 'replacement-search', + 'proactiveSearchEnabled': 'proactive-search', + 'selectionStrategy': 'selection-strategy', + 'monitoredOnly': 'monitored-only', + 'useCutoff': 'use-cutoff', + 'useCustomFormatScore': 'use-custom-format-score', + 'useRoundRobin': 'round-robin', + 'postReleaseGraceHours': 'post-release-grace-period', + 'missingSearchEnabled': 'enable-missing-search', + 'enabled': 'instance-enabled', + 'skipTags': 'instance-skip-tags', + 'activeDownloadLimit': 'instance-active-download-limit', + 'minCycleTimeDays': 'instance-min-cycle-time-days', + }, }; openFieldDocumentation(section: string, fieldName: string): void { diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.html b/code/frontend/src/app/features/dashboard/dashboard.component.html index 98f43e56..69569e31 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.html +++ b/code/frontend/src/app/features/dashboard/dashboard.component.html @@ -86,193 +86,298 @@ } -
- - -
-
-
-

Recent Strikes

- - {{ connected() ? 'Connected' : 'Disconnected' }} - -
- View All -
-
- - - Newest first - - @for (strike of recentStrikes(); track strike.id) { -
-
- -
-
-
- - {{ formatStrikeType(strike.type) }} - - {{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }} -
-

{{ truncate(strike.title) }}

-
-
- } @empty { -
- @if (!connected()) { - - Connecting... - } @else { - No recent strikes - } -
- } -
-
-
+
+ @for (rowId of visibleRowOrder(); track rowId) { +
- - -
-
-
-

Recent Logs

- - {{ connected() ? 'Connected' : 'Disconnected' }} - -
- View All -
-
- - - Newest first - - @for (log of recentLogs(); track $index) { -
-
- -
-
-
- - {{ logLevelLabel(log.level) }} - - @if (log.category && log.category !== 'SYSTEM') { - {{ log.category }} + @switch (rowId) { + + @case ('strikes') { + + +
+
+
+ +

Recent Strikes

+ + {{ connected() ? 'Connected' : 'Disconnected' }} + +
+ View All +
+
+ + + Newest first + + @for (strike of recentStrikes(); track strike.id) { +
+
+ +
+
+
+ + {{ formatStrikeType(strike.type) }} + + {{ strike.createdAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+

{{ truncate(strike.title) }}

+
+
+ } @empty { +
+ @if (!connected()) { + + Connecting... + } @else { + No recent strikes + } +
} - @if (log.downloadClientType) { - {{ log.downloadClientType }} +
+
+
+ } + + @case ('logs-events') { + +
+ +
+
+
+ +

Recent Logs

+ + {{ connected() ? 'Connected' : 'Disconnected' }} + +
+ View All +
+
+ + + Newest first + + @for (log of recentLogs(); track $index) { +
+
+ +
+
+
+ + {{ logLevelLabel(log.level) }} + + @if (log.category && log.category !== 'SYSTEM') { + {{ log.category }} + } + @if (log.downloadClientType) { + {{ log.downloadClientType }} + } + {{ log.timestamp | date:'yyyy-MM-dd HH:mm:ss' }} +
+

{{ truncate(log.message) }}

+
+
+ } @empty { +
+ @if (!connected()) { + + Connecting... + } @else { + No recent logs + } +
+ } +
+
+
+ + +
+
+
+

Recent Events

+ + {{ connected() ? 'Connected' : 'Disconnected' }} + +
+ View All +
+
+ + + Newest first + + @for (event of recentEvents(); track $index) { +
+
+ +
+
+
+ + {{ event.severity }} + + + {{ formatEventType(event.eventType) }} + + {{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }} +
+

{{ truncate(event.message) }}

+ @if (getDownloadName(event); as name) { +

{{ name }}

+ } +
+
+ } @empty { +
+ @if (!connected()) { + + Connecting... + } @else { + No recent events + } +
+ } +
+
+
+
+ } + + @case ('cf-scores') { + + @if (cfScoreStats(); as stats) { + +
+
+
+ +

Custom Format Scores

+
+ View All +
+
+
+
+ {{ stats.totalTracked }} + Tracked +
+
+ {{ stats.belowCutoff }} + Below Cutoff +
+
+ {{ stats.atOrAboveCutoff }} + Met Cutoff +
+
+ {{ stats.recentUpgrades }} + Upgrades (7d) +
+
+ @if (stats.perInstanceStats.length > 0) { +
+ @for (inst of stats.perInstanceStats; track inst.instanceId) { +
+
+ {{ inst.instanceName }} + + {{ inst.instanceType }} + +
+
+ {{ inst.totalTracked }} tracked + {{ inst.belowCutoff }} below + {{ inst.atOrAboveCutoff }} met + {{ inst.recentUpgrades }} upgrades +
+
+ } +
+ } + @if (cfScoreUpgrades().length > 0) { +
+
+ Recent Upgrades + View All +
+ @for (upgrade of cfScoreUpgrades(); track $index) { +
+
{{ upgrade.title }}
+
+ {{ upgrade.previousScore }} + + {{ upgrade.newScore }} + (cutoff: {{ upgrade.cutoffScore }}) +
+ {{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }} +
+ } +
+ } +
+
+
+ } + } + + @case ('jobs') { + + +
+
+
+ +

Jobs

+
+
+
+ @for (job of jobs(); track job.jobType) { +
+
+ {{ jobDisplayName(job.jobType) }} + + {{ job.status }} + +
+
+ @if (job.nextRunTime) { + Next: {{ job.nextRunTime | date:'HH:mm:ss' }} + } + {{ job.schedule }} +
+ @if (job.jobType !== JobType.Seeker) { + + Run Now + + } +
+ } @empty { +
+ @if (!connected()) { + + Connecting... + } @else { + No jobs configured + } +
} - {{ log.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
-

{{ truncate(log.message) }}

-
- } @empty { -
- @if (!connected()) { - - Connecting... - } @else { - No recent logs - } -
+ } -
-
- - - -
-
-
-

Recent Events

- - {{ connected() ? 'Connected' : 'Disconnected' }} - -
- View All -
-
- - - Newest first - - @for (event of recentEvents(); track $index) { -
-
- -
-
-
- - {{ event.severity }} - - - {{ formatEventType(event.eventType) }} - - {{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }} -
-

{{ truncate(event.message) }}

- @if (getDownloadName(event); as name) { -

{{ name }}

- } -
-
- } @empty { -
- @if (!connected()) { - - Connecting... - } @else { - No recent events - } -
- } -
+ }
-
- - - -
-
-

Jobs

-
-
- @for (job of jobs(); track job.jobType) { -
-
- {{ jobDisplayName(job.jobType) }} - - {{ job.status }} - -
-
- @if (job.nextRunTime) { - Next: {{ job.nextRunTime | date:'HH:mm:ss' }} - } - {{ job.schedule }} -
- - Run Now - -
- } @empty { -
- @if (!connected()) { - - Connecting... - } @else { - No jobs configured - } -
- } -
-
-
+ }
diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.scss b/code/frontend/src/app/features/dashboard/dashboard.component.scss index 83021712..18eed75f 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/code/frontend/src/app/features/dashboard/dashboard.component.scss @@ -109,8 +109,25 @@ 100% { transform: scale(1.15); } } -// Dashboard grid -.dashboard-grid { +// Dashboard rows (drag-and-drop) +.dashboard-rows { + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.dashboard-row { + min-width: 0; + animation: slide-up var(--duration-normal) var(--ease-default) both; + + &:nth-child(1) { animation-delay: 0ms; } + &:nth-child(2) { animation-delay: 80ms; } + &:nth-child(3) { animation-delay: 160ms; } + &:nth-child(4) { animation-delay: 240ms; } +} + +// Logs + Events side-by-side row +.logs-events-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-6); @@ -118,25 +135,127 @@ @media (max-width: 1024px) { grid-template-columns: 1fr; } +} - &__strikes { - grid-column: 1 / -1; +// Drag handle icon inside card headers +.drag-handle__icon { + font-size: 18px; + color: var(--text-secondary); + cursor: grab; + opacity: 0.6; + flex-shrink: 0; + transition: opacity var(--duration-fast) var(--ease-default), color var(--duration-fast) var(--ease-default); + + &:hover { + opacity: 1; + color: var(--text-primary); } - &__jobs { - grid-column: 1 / -1; + &:active { + cursor: grabbing; + } +} + +// CDK drag-and-drop styles +.cdk-drag-preview { + border-radius: var(--radius-lg); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + opacity: 0.9; + cursor: grabbing; +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms var(--ease-default); +} + +.dashboard-rows.cdk-drop-list-dragging .dashboard-row:not(.cdk-drag-placeholder) { + transition: transform 250ms var(--ease-default); +} + +// CF Scores card +.cf-scores { + padding: var(--space-4) var(--space-5); + + &__stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-4); + @media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); } } - // Staggered entrance animations - > app-card { - min-width: 0; // Allow grid items to shrink below content size - animation: slide-up var(--duration-normal) var(--ease-default) both; - - &:nth-child(1) { animation-delay: 0ms; } - &:nth-child(2) { animation-delay: 80ms; } - &:nth-child(3) { animation-delay: 160ms; } - &:nth-child(4) { animation-delay: 240ms; } + &__stat { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-3); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.03); + &--warning { background: rgba(245, 158, 11, 0.06); } + &--success { background: rgba(34, 197, 94, 0.06); } + &--info { background: rgba(59, 130, 246, 0.06); } } + + &__stat-value { font-size: var(--font-size-xl); font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; } + &__stat-label { font-size: var(--font-size-xs); color: var(--text-tertiary); } + + &__instances { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--divider); + } + + &__instance { + padding: var(--space-3); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.03); + } + + &__instance-header { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + + &__instance-name { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-primary); + } + + &__instance-stats { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + &__instance-stat { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + &--warning { color: var(--color-warning); } + &--success { color: var(--color-success); } + &--info { color: var(--color-info); } + } + + &__upgrades { border-top: 1px solid var(--divider); padding-top: var(--space-3); } + &__upgrades-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-2); } + &__upgrades-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-secondary); } + &__upgrade-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; &:not(:last-child) { border-bottom: 1px solid var(--divider); } } + &__upgrade-title { flex: 1; min-width: 0; font-size: var(--font-size-sm); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + &__upgrade-scores { display: flex; align-items: center; gap: var(--space-1); flex-shrink: 0; } + &__score { font-size: var(--font-size-sm); font-weight: 600; &--old { color: var(--text-tertiary); } &--new { color: var(--color-success); } } + &__arrow { font-size: 12px; color: var(--text-tertiary); } + &__cutoff, &__upgrade-time { font-size: var(--font-size-xs); color: var(--text-tertiary); } + &__upgrade-time { font-family: var(--font-mono); } } // Manual event action-required banner diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.ts b/code/frontend/src/app/features/dashboard/dashboard.component.ts index 0645e6f2..733067ae 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.ts +++ b/code/frontend/src/app/features/dashboard/dashboard.component.ts @@ -2,17 +2,23 @@ import { Component, ChangeDetectionStrategy, inject, computed, signal, OnInit } import { Router, RouterLink } from '@angular/router'; import { DatePipe, JsonPipe } from '@angular/common'; import { NgIcon } from '@ng-icons/core'; +import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle, moveItemInArray } from '@angular/cdk/drag-drop'; import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, ButtonComponent, BadgeComponent, SpinnerComponent } from '@ui'; import { AppHubService } from '@core/realtime/app-hub.service'; import { EventsApi } from '@core/api/events.api'; import { JobsApi } from '@core/api/jobs.api'; import { GeneralConfigApi } from '@core/api/general-config.api'; +import { CfScoreApi, CfScoreStats, CfScoreUpgrade } from '@core/api/cf-score.api'; import { ToastService } from '@core/services/toast.service'; import { LogEntry } from '@core/models/signalr.models'; import { ManualEvent } from '@core/models/event.models'; import { JobType } from '@shared/models/enums'; +const DASHBOARD_ROW_ORDER_KEY = 'dashboard-row-order'; +const DEFAULT_ROW_ORDER = ['strikes', 'logs-events', 'cf-scores', 'jobs'] as const; +type DashboardRowId = typeof DEFAULT_ROW_ORDER[number]; + @Component({ selector: 'app-dashboard', standalone: true, @@ -26,22 +32,36 @@ import { JobType } from '@shared/models/enums'; ButtonComponent, BadgeComponent, SpinnerComponent, + CdkDropList, + CdkDrag, + CdkDragHandle, ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class DashboardComponent implements OnInit { + readonly JobType = JobType; + private readonly hub = inject(AppHubService); private readonly eventsApi = inject(EventsApi); private readonly jobsApi = inject(JobsApi); private readonly generalConfigApi = inject(GeneralConfigApi); + private readonly cfScoreApi = inject(CfScoreApi); private readonly toast = inject(ToastService); private readonly router = inject(Router); readonly connected = this.hub.isConnected; readonly jobs = this.hub.jobs; readonly showSupportSection = signal(false); + readonly cfScoreStats = signal(null); + readonly cfScoreUpgrades = signal([]); + + readonly rowOrder = signal(this.loadOrder()); + readonly visibleRowOrder = computed(() => { + const order = this.rowOrder(); + return this.cfScoreStats() ? order : order.filter((id) => id !== 'cf-scores'); + }); readonly recentStrikes = computed(() => this.hub.strikes().slice(0, 5)); readonly recentLogs = computed(() => this.hub.logs().slice(0, 5)); @@ -68,6 +88,16 @@ export class DashboardComponent implements OnInit { this.generalConfigApi.get().subscribe({ next: (config) => this.showSupportSection.set(config.displaySupportBanner), }); + this.loadCfScoreData(); + } + + private loadCfScoreData(): void { + this.cfScoreApi.getStats().subscribe({ + next: (stats) => this.cfScoreStats.set(stats), + }); + this.cfScoreApi.getRecentUpgrades(1, 5).subscribe({ + next: (res) => this.cfScoreUpgrades.set(res.items), + }); } // Manual event navigation @@ -247,6 +277,33 @@ export class DashboardComponent implements OnInit { this.router.navigate([path]); } + private loadOrder(): DashboardRowId[] { + try { + const saved = localStorage.getItem(DASHBOARD_ROW_ORDER_KEY); + if (saved) { + const parsed: unknown[] = JSON.parse(saved); + const valid = parsed.filter((id): id is DashboardRowId => + (DEFAULT_ROW_ORDER as readonly unknown[]).includes(id) + ); + // Ensure any newly added rows (future-proofing) are appended + for (const id of DEFAULT_ROW_ORDER) { + if (!valid.includes(id)) valid.push(id); + } + return valid; + } + } catch { /* ignore */ } + return [...DEFAULT_ROW_ORDER]; + } + + onDrop(event: CdkDragDrop): void { + const visible = [...this.visibleRowOrder()]; + moveItemInArray(visible, event.previousIndex, event.currentIndex); + const hidden = this.rowOrder().filter((id) => !visible.includes(id)); + const newOrder = [...visible, ...hidden]; + this.rowOrder.set(newOrder); + localStorage.setItem(DASHBOARD_ROW_ORDER_KEY, JSON.stringify(newOrder)); + } + // Strike helpers strikeTypeSeverity(type: string): 'error' | 'warning' | 'info' | 'default' { const t = type.toLowerCase(); @@ -259,4 +316,10 @@ export class DashboardComponent implements OnInit { formatStrikeType(type: string): string { return type.replace(/([A-Z])/g, ' $1').trim(); } + + instanceTypeSeverity(type: string): 'info' | 'warning' | 'default' { + if (type === 'Radarr') return 'warning'; + if (type === 'Sonarr') return 'info'; + return 'default'; + } } diff --git a/code/frontend/src/app/features/events/events.component.html b/code/frontend/src/app/features/events/events.component.html index d66aa690..01b6515a 100644 --- a/code/frontend/src/app/features/events/events.component.html +++ b/code/frontend/src/app/features/events/events.component.html @@ -35,7 +35,7 @@ placeholder="Search events..." type="search" [(value)]="searchQuery" - (blurred)="onFilterChange()" + (entered)="onFilterChange()" />
diff --git a/code/frontend/src/app/features/logs-component/logs.component.html b/code/frontend/src/app/features/logs-component/logs.component.html index c2dccd93..b809ca47 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.html +++ b/code/frontend/src/app/features/logs-component/logs.component.html @@ -17,6 +17,11 @@ [options]="categoryOptions()" [(value)]="selectedCategory" /> + (''); readonly selectedCategory = signal(''); + readonly selectedJobName = signal(''); readonly searchQuery = signal(''); readonly expandedIndex = signal(null); readonly showExportMenu = signal(false); @@ -65,6 +66,7 @@ export class LogsComponent implements OnInit { let logs = this.hub.logs(); const level = this.selectedLevel() as string; const category = this.selectedCategory() as string; + const jobName = this.selectedJobName() as string; const query = this.searchQuery().toLowerCase(); const runId = this.selectedJobRunId(); @@ -77,6 +79,9 @@ export class LogsComponent implements OnInit { if (category) { logs = logs.filter((l) => l.category === category); } + if (jobName) { + logs = logs.filter((l) => l.jobName === jobName); + } if (query) { logs = logs.filter( (l) => @@ -102,6 +107,14 @@ export class LogsComponent implements OnInit { ]; }); + readonly jobNameOptions = computed(() => { + const names = new Set(this.hub.logs().map((l) => l.jobName).filter(Boolean)); + return [ + { label: 'All Jobs', value: '' }, + ...Array.from(names).sort().map((n) => ({ label: this.jobDisplayName(n!), value: n! })), + ]; + }); + isExpandable(log: LogEntry): boolean { return !!(log.exception || log.jobName || log.instanceName || log.downloadClientType || log.jobRunId); } diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html new file mode 100644 index 00000000..7a1726f7 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html @@ -0,0 +1,166 @@ + +
+
+ + + + +
+
+ + Refresh + +
+
+ + +@if (stats()) { +
+
+ + Tracked +
+
+ + Below Cutoff +
+
+ + Met Cutoff +
+
+ + Monitored +
+
+ + Unmonitored +
+ +
+ + Recent Upgrades +
+
+
+} + + + +
+ @for (item of items(); track item.id) { +
+
+ + {{ item.title }} + + {{ item.currentScore }} + / + {{ item.cutoffScore }} + + {{ item.qualityProfileName }} + + {{ statusLabel(item.isBelowCutoff) }} + + + {{ item.isMonitored ? 'Monitored' : 'Unmonitored' }} + + + {{ item.itemType }} + + {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm' }} + +
+ + @if (expandedId() === item.id) { +
+
+ Quality Profile + {{ item.qualityProfileName }} +
+
+ Current Score + {{ item.currentScore }} +
+
+ Cutoff Score + {{ item.cutoffScore }} +
+
+ Last Synced + {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ + +
+ Score History + @if (historyLoading()) { + Loading... + } @else if (historyEntries().length === 0) { + No history available + } @else { +
+
+ Score + Cutoff + Recorded At +
+ @for (entry of historyEntries(); track entry.recordedAt) { +
+ {{ entry.score }} + {{ entry.cutoffScore }} + {{ entry.recordedAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ } +
+ } +
+
+ } +
+ } @empty { + + } +
+
+ + +@if (totalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss new file mode 100644 index 00000000..ec03e2d5 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss @@ -0,0 +1,287 @@ +@use 'data-toolbar' as *; + +// Staggered animations +:host { + > .toolbar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 40ms; + position: relative; + z-index: 1; + } + > .stats-bar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 80ms; + } + > app-card { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 120ms; + } + > app-paginator { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 160ms; + } +} + +.toolbar { + @include data-toolbar; + + &__filters { + app-input { + flex: 1; + min-width: 150px; + max-width: 300px; + } + } +} + +// Stats bar +.stats-bar { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-3); + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + + &--warning { color: var(--color-warning); } + &--success { color: var(--color-success); } + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +} + +// Scores list +.scores-list { + max-height: 70vh; + overflow-y: auto; +} + +.score-row { + border-bottom: 1px solid var(--divider); + transition: background var(--duration-fast) var(--ease-default); + font-size: var(--font-size-sm); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + transform: translateX(-100%); + transition: transform var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: 0; + } + + &:hover::before { + transform: translateX(100%); + } + + &:hover { + background: var(--glass-bg); + } + + &--expanded { + background: var(--glass-bg); + } + + &__main { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + min-height: 44px; + cursor: pointer; + } + + &__icon { + color: var(--color-primary); + font-size: 18px; + flex-shrink: 0; + } + + &__title { + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; + word-break: break-word; + } + + &__scores { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; + } + + &__current { + font-weight: 600; + color: var(--text-primary); + } + + &__separator { + color: var(--text-tertiary); + } + + &__cutoff { + color: var(--text-tertiary); + } + + &__profile { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + } + + &__chevron { + font-size: 14px; + color: var(--text-tertiary); + flex-shrink: 0; + margin-left: auto; + transition: color var(--duration-fast) var(--ease-default); + } + + &__main:hover &__chevron { + color: var(--text-secondary); + } + + // Expanded details + &__details { + padding: var(--space-2) var(--space-4) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-3); + animation: fade-in var(--duration-fast) var(--ease-default); + } + + &__detail { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__detail-label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__detail-value { + font-size: var(--font-size-sm); + color: var(--text-secondary); + word-break: break-word; + } +} + +// History table +.history-table { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + overflow: hidden; + + &__header { + display: grid; + grid-template-columns: 80px 80px 1fr; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--divider); + } + + &__row { + display: grid; + grid-template-columns: 80px 80px 1fr; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + align-items: center; + border-bottom: 1px solid var(--divider); + + &:last-child { + border-bottom: none; + } + } + + &__score { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-primary); + } + + &__cutoff { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__time { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-secondary); + } +} + +// Tablet +@media (max-width: 1024px) { + .score-row__main { + flex-wrap: wrap; + } + + .score-row__profile { + display: none; + } +} + +// Mobile +@media (max-width: 768px) { + .stats-bar { + flex-wrap: wrap; + gap: var(--space-3); + } + + .score-row__main { + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + } + + .score-row__profile { + display: none; + } + + .score-row__time { + order: 4; + flex-basis: 100%; + } +} diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts new file mode 100644 index 00000000..df87adae --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts @@ -0,0 +1,199 @@ +import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { NgIcon } from '@ng-icons/core'; +import { + CardComponent, BadgeComponent, ButtonComponent, InputComponent, + PaginatorComponent, EmptyStateComponent, SelectComponent, ToggleComponent, + TooltipComponent, +} from '@ui'; +import type { SelectOption } from '@ui'; +import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; +import { + CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry, +} from '@core/api/cf-score.api'; +import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; + +@Component({ + selector: 'app-quality-tab', + standalone: true, + imports: [ + DatePipe, + NgIcon, + CardComponent, + BadgeComponent, + ButtonComponent, + InputComponent, + SelectComponent, + ToggleComponent, + PaginatorComponent, + EmptyStateComponent, + AnimatedCounterComponent, + TooltipComponent, + ], + templateUrl: './quality-tab.component.html', + styleUrl: './quality-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QualityTabComponent implements OnInit { + private readonly api = inject(CfScoreApi); + private readonly hub = inject(AppHubService); + private readonly toast = inject(ToastService); + private initialLoad = true; + + readonly items = signal([]); + readonly stats = signal(null); + readonly totalRecords = signal(0); + readonly loading = signal(false); + + readonly currentPage = signal(1); + readonly pageSize = signal(50); + readonly searchQuery = signal(''); + readonly selectedInstanceId = signal(''); + readonly instanceOptions = signal([]); + + readonly sortBy = signal('title'); + readonly hideMet = signal(false); + readonly sortOptions: SelectOption[] = [ + { label: 'Title', value: 'title' }, + { label: 'Last Synced', value: 'date' }, + ]; + + readonly displayStats = computed(() => { + const s = this.stats(); + if (!s) return null; + const instanceId = this.selectedInstanceId(); + if (instanceId) { + return s.perInstanceStats.find(i => i.instanceId === instanceId) ?? null; + } + return s; + }); + + readonly expandedId = signal(null); + readonly historyEntries = signal([]); + readonly historyLoading = signal(false); + + constructor() { + effect(() => { + this.hub.cfScoresVersion(); + if (this.initialLoad) { + this.initialLoad = false; + return; + } + this.loadScores(); + this.loadStats(); + }); + } + + ngOnInit(): void { + this.loadInstances(); + this.loadScores(); + this.loadStats(); + } + + loadScores(): void { + this.loading.set(true); + this.api.getScores(this.currentPage(), this.pageSize(), this.searchQuery() || undefined, this.selectedInstanceId() || undefined, this.sortBy(), this.hideMet()).subscribe({ + next: (result) => { + this.items.set(result.items); + this.totalRecords.set(result.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.toast.error('Failed to load CF scores'); + }, + }); + } + + private loadInstances(): void { + this.api.getInstances().subscribe({ + next: (result) => { + this.instanceOptions.set([ + { label: 'All Instances', value: '' }, + ...result.instances.map(i => ({ + label: `${i.name} (${i.itemType})`, + value: i.id, + })), + ]); + }, + error: () => this.toast.error('Failed to load instances'), + }); + } + + onInstanceFilterChange(value: string): void { + this.selectedInstanceId.set(value); + this.currentPage.set(1); + this.loadScores(); + } + + private loadStats(): void { + this.api.getStats().subscribe({ + next: (stats) => this.stats.set(stats), + error: () => this.toast.error('Failed to load CF score stats'), + }); + } + + onFilterChange(): void { + this.currentPage.set(1); + this.loadScores(); + } + + onSortChange(value: string): void { + this.sortBy.set(value); + this.currentPage.set(1); + this.loadScores(); + } + + onHideMetChange(value: boolean): void { + this.hideMet.set(value); + this.currentPage.set(1); + this.loadScores(); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + this.loadScores(); + } + + refresh(): void { + this.loadScores(); + this.loadStats(); + } + + toggleExpand(item: CfScoreEntry): void { + const id = item.id; + if (this.expandedId() === id) { + this.expandedId.set(null); + this.historyEntries.set([]); + return; + } + + this.expandedId.set(id); + this.historyLoading.set(true); + this.historyEntries.set([]); + + this.api.getItemHistory(item.arrInstanceId, item.externalItemId, item.episodeId).subscribe({ + next: (res) => { + this.historyEntries.set(res.entries); + this.historyLoading.set(false); + }, + error: () => { + this.historyLoading.set(false); + this.toast.error('Failed to load score history'); + }, + }); + } + + statusSeverity(isBelowCutoff: boolean): 'warning' | 'success' { + return isBelowCutoff ? 'warning' : 'success'; + } + + statusLabel(isBelowCutoff: boolean): string { + return isBelowCutoff ? 'Below Cutoff' : 'Met'; + } + + itemTypeSeverity(itemType: string): 'info' | 'default' { + return itemType === 'Radarr' || itemType === 'Sonarr' ? 'info' : 'default'; + } +} diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html new file mode 100644 index 00000000..fdd797c9 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html @@ -0,0 +1,187 @@ + +@if (summary(); as stats) { +
+
+ + Total Searches +
+
+ + Last 7 Days +
+
+ + Last 30 Days +
+ +
+ + Unique Items +
+
+ +
+ + + + Pending Replacements +
+
+
+ + Instances +
+
+ + + @if (stats.perInstanceStats.length > 0) { +
+ @for (inst of sortedInstanceStats(); track inst.instanceId) { + +
+ {{ inst.instanceName }} +
+ @if (instanceHealthWarning(inst); as warning) { + + + + } + + {{ inst.instanceType }} + +
+
+ +
+
+ + Cycle Progress + + + {{ inst.cycleItemsSearched }} / {{ inst.cycleItemsTotal }} + +
+
+
+
+
+ +
+
+ {{ inst.totalSearchCount }} + Searches +
+
+ + {{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }} + + + Cycle Duration + +
+
+ + {{ inst.lastSearchedAt ? (inst.lastSearchedAt | date:'MM/dd HH:mm') : 'Never' }} + + + Last Search + +
+
+ + +
+ } +
+ } +} + + +
+
+ + + +
+
+ + Refresh + +
+
+ + + +
+ @for (event of events(); track event.id) { +
+
+ + + {{ event.items.length > 0 ? event.items[0] : 'Search triggered' }} + @if (event.items.length > 1) { + +{{ event.items.length - 1 }} more + } + + + {{ event.searchType }} + + @if (event.searchStatus) { + + {{ event.searchStatus }} + + } + @if (event.isDryRun) { + Dry Run + } + @if (event.cycleId) { + {{ event.cycleId.substring(0, 8) }} + } + {{ event.instanceName }} + {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }} +
+ @if (event.grabbedItems && event.grabbedItems.length > 0) { +
+ + + Grabbed: {{ formatGrabbedItems(event.grabbedItems) }} + +
+ } +
+ } @empty { + + } +
+
+ +@if (eventsTotalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss new file mode 100644 index 00000000..f87daaeb --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss @@ -0,0 +1,361 @@ +@use 'data-toolbar' as *; + +// Staggered animations +:host { + > .stats-bar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 40ms; + } + > .instance-cards { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 80ms; + } + > .toolbar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 120ms; + position: relative; + z-index: 1; + } + > app-card { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 160ms; + } + > app-paginator { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 200ms; + } +} + +.toolbar { + @include data-toolbar; + + &__filters { + app-input { + flex: 1; + min-width: 150px; + max-width: 300px; + } + } +} + +// Stats bar +.stats-bar { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-3); + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + + &--warning { color: var(--color-warning); } + &--success { color: var(--color-success); } + &--info { color: var(--color-primary); } + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +} + +// Instance cards +.instance-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.instance-card { + position: relative; + z-index: 0; + + &:has(.tooltip-bubble--visible) { + z-index: 1; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-3); + } + + &__header-right { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + } + + &__name { + font-weight: 600; + font-size: var(--font-size-base); + color: var(--text-primary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__warning-icon { + color: var(--color-warning); + font-size: 16px; + flex-shrink: 0; + } + + &__progress { + margin-bottom: var(--space-3); + } + + &__progress-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-1-5); + } + + &__progress-label { + font-size: var(--font-size-xs); + font-weight: 500; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + &__progress-count { + font-family: var(--font-mono); + font-size: var(--font-size-sm); + color: var(--text-secondary); + flex-shrink: 0; + + strong { + color: var(--text-primary); + font-weight: 600; + } + } + + &__progress-track { + height: 5px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-full); + overflow: hidden; + } + + &__progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 75%, #c084fc)); + border-radius: var(--radius-full); + transition: width var(--duration-normal) var(--ease-default); + min-width: 0; + } + + &__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--divider); + } + + &__stat { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; + } + + &__stat-value { + font-size: var(--font-size-lg); + font-weight: 600; + font-family: var(--font-mono); + color: var(--text-primary); + line-height: 1.2; + + &--small { + font-size: var(--font-size-sm); + font-family: inherit; + font-weight: 500; + } + } + + &__stat-label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__footer { + display: flex; + justify-content: flex-end; + margin-top: var(--space-3); + padding-top: var(--space-2); + border-top: 1px solid var(--divider); + } + + &__cycle-chip { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + background: var(--glass-bg); + padding: 2px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + cursor: default; + } +} + +// List rows +.list { + max-height: 70vh; + overflow-y: auto; +} + +.list-row { + border-bottom: 1px solid var(--divider); + transition: background var(--duration-fast) var(--ease-default); + font-size: var(--font-size-sm); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + transform: translateX(-100%); + transition: transform var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: 0; + } + + &:hover::before { + transform: translateX(100%); + } + + &:hover { + background: var(--glass-bg); + } + + &__main { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + min-height: 44px; + } + + &__icon { + color: var(--color-primary); + font-size: 18px; + flex-shrink: 0; + } + + &__title { + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; + word-break: break-word; + } + + &__extra { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + margin-left: var(--space-1); + } + + &__meta { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__cycle { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + background: var(--glass-bg); + padding: 1px 6px; + border-radius: var(--radius-sm); + flex-shrink: 0; + } + + &__time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + } + + &__detail { + display: flex; + align-items: center; + gap: var(--space-1); + padding: 0 var(--space-4) var(--space-2) calc(var(--space-4) + 18px + var(--space-2)); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__detail-icon { + font-size: 14px; + color: var(--color-success); + flex-shrink: 0; + } + + &__detail-text { + min-width: 0; + word-break: break-word; + } +} + +// Tablet +@media (max-width: 1024px) { + .list-row__main { + flex-wrap: wrap; + } + + .list-row__meta { + display: none; + } +} + +// Mobile +@media (max-width: 768px) { + .stats-bar { + flex-wrap: wrap; + gap: var(--space-3); + } + + .instance-cards { + grid-template-columns: 1fr; + } + + .list-row__main { + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + } + + .list-row__meta { + display: none; + } + + .list-row__time { + order: 4; + flex-basis: 100%; + } +} diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts new file mode 100644 index 00000000..c62f1eca --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts @@ -0,0 +1,214 @@ +import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { NgIcon } from '@ng-icons/core'; +import { + CardComponent, BadgeComponent, ButtonComponent, SelectComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, TooltipComponent, +} from '@ui'; +import type { SelectOption } from '@ui'; +import type { BadgeSeverity } from '@ui/badge/badge.component'; +import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; +import { SearchStatsApi } from '@core/api/search-stats.api'; +import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models'; +import { SeekerSearchType } from '@core/models/search-stats.models'; +import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; + +type CycleFilter = 'current' | 'all'; + +@Component({ + selector: 'app-searches-tab', + standalone: true, + imports: [ + DatePipe, + NgIcon, + CardComponent, + BadgeComponent, + ButtonComponent, + SelectComponent, + InputComponent, + PaginatorComponent, + EmptyStateComponent, + AnimatedCounterComponent, + TooltipComponent, + ], + templateUrl: './searches-tab.component.html', + styleUrl: './searches-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchesTabComponent implements OnInit { + private readonly api = inject(SearchStatsApi); + private readonly hub = inject(AppHubService); + private readonly toast = inject(ToastService); + private initialLoad = true; + + readonly summary = signal(null); + readonly loading = signal(false); + + readonly sortedInstanceStats = computed(() => + [...(this.summary()?.perInstanceStats ?? [])].sort((a, b) => { + const typeCompare = a.instanceType.localeCompare(b.instanceType); + return typeCompare !== 0 ? typeCompare : a.instanceName.localeCompare(b.instanceName); + }) + ); + + // Instance filter + readonly selectedInstanceId = signal(''); + readonly instanceOptions = signal([]); + + // Cycle filter + readonly cycleFilter = signal('current'); + readonly cycleFilterOptions: SelectOption[] = [ + { label: 'Current Cycle', value: 'current' }, + { label: 'All Time', value: 'all' }, + ]; + + // Search filter + readonly searchQuery = signal(''); + + // Events + readonly events = signal([]); + readonly eventsTotalRecords = signal(0); + readonly eventsPage = signal(1); + readonly pageSize = signal(50); + + constructor() { + effect(() => { + this.hub.searchStatsVersion(); + if (this.initialLoad) { + this.initialLoad = false; + return; + } + this.loadSummary(); + this.loadEvents(); + }); + } + + ngOnInit(): void { + this.loadSummary(); + this.loadEvents(); + } + + onInstanceFilterChange(value: string): void { + this.selectedInstanceId.set(value); + if (!value) { + this.cycleFilter.set('all'); + } + this.eventsPage.set(1); + this.loadEvents(); + } + + onCycleFilterChange(value: string): void { + this.cycleFilter.set(value as CycleFilter); + this.eventsPage.set(1); + this.loadEvents(); + } + + onSearchFilterChange(): void { + this.eventsPage.set(1); + this.loadEvents(); + } + + onEventsPageChange(page: number): void { + this.eventsPage.set(page); + this.loadEvents(); + } + + refresh(): void { + this.loadSummary(); + this.loadEvents(); + } + + searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' { + return type === SeekerSearchType.Replacement ? 'warning' : 'info'; + } + + instanceTypeSeverity(type: string): BadgeSeverity { + if (type === 'Radarr') return 'warning'; + if (type === 'Sonarr') return 'info'; + return 'default'; + } + + searchStatusSeverity(status: string): BadgeSeverity { + switch (status) { + case 'Completed': return 'success'; + case 'Failed': return 'error'; + case 'TimedOut': return 'warning'; + case 'Started': return 'info'; + default: return 'default'; + } + } + + formatGrabbedItems(items: unknown[]): string { + return items.map((i: any) => i.Title || i.title || 'Unknown').join(', '); + } + + cycleProgress(inst: InstanceSearchStat): number { + if (!inst.cycleItemsTotal) return 0; + return Math.min(100, Math.round((inst.cycleItemsSearched / inst.cycleItemsTotal) * 100)); + } + + instanceHealthWarning(stat: InstanceSearchStat): string | null { + if (!stat.lastSearchedAt && stat.totalSearchCount === 0) { + return 'Never searched'; + } + return null; + } + + formatCycleDuration(cycleStartedAt: string): string { + const start = new Date(cycleStartedAt); + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + + if (diffDays > 0) { + return `${diffDays}d ${diffHours}h`; + } + if (diffHours > 0) { + return `${diffHours}h`; + } + const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${diffMinutes}m`; + } + + private loadSummary(): void { + this.api.getSummary().subscribe({ + next: (summary) => { + this.summary.set(summary); + this.instanceOptions.set([ + { label: 'All Instances', value: '' }, + ...summary.perInstanceStats.map(s => ({ + label: s.instanceName, + value: s.instanceId, + })), + ]); + }, + error: () => this.toast.error('Failed to load search stats'), + }); + } + + private loadEvents(): void { + this.loading.set(true); + const instanceId = this.selectedInstanceId() || undefined; + const search = this.searchQuery() || undefined; + let cycleId: string | undefined; + + if (this.cycleFilter() === 'current' && instanceId) { + const instance = this.summary()?.perInstanceStats.find(s => s.instanceId === instanceId); + cycleId = instance?.currentCycleId ?? undefined; + } + + this.api.getEvents(this.eventsPage(), this.pageSize(), instanceId, cycleId, search).subscribe({ + next: (result) => { + this.events.set(result.items); + this.eventsTotalRecords.set(result.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.toast.error('Failed to load search events'); + }, + }); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html new file mode 100644 index 00000000..65880000 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html @@ -0,0 +1,22 @@ + + +
+ + +
+ @switch (activeTab()) { + @case ('searches') { + + } + @case ('quality') { + + } + @case ('upgrades') { + + } + } +
+
diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss new file mode 100644 index 00000000..df9b7e47 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss @@ -0,0 +1,10 @@ +.page-content { + > app-tabs { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 0ms; + } + + > .tab-content { + margin-top: var(--space-6); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts new file mode 100644 index 00000000..3d88cd63 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts @@ -0,0 +1,53 @@ +import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { TabsComponent } from '@ui'; +import type { Tab } from '@ui'; +import { SearchesTabComponent } from './searches-tab/searches-tab.component'; +import { QualityTabComponent } from './quality-tab/quality-tab.component'; +import { UpgradesTabComponent } from './upgrades-tab/upgrades-tab.component'; + +type SeekerTab = 'searches' | 'quality' | 'upgrades'; + +@Component({ + selector: 'app-seeker-stats', + standalone: true, + imports: [ + PageHeaderComponent, + TabsComponent, + SearchesTabComponent, + QualityTabComponent, + UpgradesTabComponent, + ], + templateUrl: './seeker-stats.component.html', + styleUrl: './seeker-stats.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SeekerStatsComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + readonly activeTab = signal('searches'); + + readonly tabs: Tab[] = [ + { id: 'searches', label: 'Searches' }, + { id: 'quality', label: 'Quality Scores' }, + { id: 'upgrades', label: 'Upgrades' }, + ]; + + ngOnInit(): void { + const tab = this.route.snapshot.queryParamMap.get('tab'); + if (tab && ['searches', 'quality', 'upgrades'].includes(tab)) { + this.activeTab.set(tab); + } + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId); + this.router.navigate([], { + relativeTo: this.route, + queryParams: { tab: tabId }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html new file mode 100644 index 00000000..d975b9fb --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html @@ -0,0 +1,69 @@ + +
+
+ + +
+
+ + Refresh + +
+
+ + +
+
+ + Total Upgrades +
+
+ + + +
+ @for (upgrade of upgrades(); track $index) { +
+
+ + {{ upgrade.title }} +
+ {{ upgrade.previousScore }} + + {{ upgrade.newScore }} + (cutoff: {{ upgrade.cutoffScore }}) +
+ + {{ upgrade.itemType }} + + {{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }} +
+
+ } @empty { + + } +
+
+ + +@if (totalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss new file mode 100644 index 00000000..30fb111d --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss @@ -0,0 +1,171 @@ +@use 'data-toolbar' as *; + +// Staggered animations +:host { + > .toolbar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 40ms; + position: relative; + z-index: 1; + } + > .stats-bar { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 80ms; + } + > app-card { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 120ms; + } + > app-paginator { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 160ms; + } +} + +.toolbar { + @include data-toolbar; +} + +// Stats bar +.stats-bar { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-3); + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + + &--success { color: var(--color-success); } + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +} + +// Upgrades list +.upgrades-list { + max-height: 70vh; + overflow-y: auto; +} + +.upgrade-row { + border-bottom: 1px solid var(--divider); + transition: background var(--duration-fast) var(--ease-default); + font-size: var(--font-size-sm); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + transform: translateX(-100%); + transition: transform var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: 0; + } + + &:hover::before { + transform: translateX(100%); + } + + &:hover { + background: var(--glass-bg); + } + + &__main { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + min-height: 44px; + } + + &__icon { + color: var(--color-success); + font-size: 18px; + flex-shrink: 0; + } + + &__title { + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; + word-break: break-word; + } + + &__scores { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + } + + &__score { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + + &--old { + color: var(--text-tertiary); + } + + &--new { + color: var(--color-success); + } + } + + &__arrow { + font-size: 14px; + color: var(--text-tertiary); + } + + &__cutoff { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + } +} + +// Tablet +@media (max-width: 1024px) { + .upgrade-row__main { + flex-wrap: wrap; + } +} + +// Mobile +@media (max-width: 768px) { + .upgrade-row__main { + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + } + + .upgrade-row__scores { + flex-basis: 100%; + order: 3; + } + + .upgrade-row__time { + order: 4; + flex-basis: 100%; + } +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts new file mode 100644 index 00000000..e1a46f03 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts @@ -0,0 +1,128 @@ +import { Component, ChangeDetectionStrategy, inject, signal, effect, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { NgIcon } from '@ng-icons/core'; +import { + CardComponent, BadgeComponent, ButtonComponent, SelectComponent, + PaginatorComponent, EmptyStateComponent, +} from '@ui'; +import type { SelectOption } from '@ui'; +import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; +import { CfScoreApi, CfScoreUpgrade } from '@core/api/cf-score.api'; +import { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; + +@Component({ + selector: 'app-upgrades-tab', + standalone: true, + imports: [ + DatePipe, + NgIcon, + CardComponent, + BadgeComponent, + ButtonComponent, + SelectComponent, + PaginatorComponent, + EmptyStateComponent, + AnimatedCounterComponent, + ], + templateUrl: './upgrades-tab.component.html', + styleUrl: './upgrades-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpgradesTabComponent implements OnInit { + private readonly api = inject(CfScoreApi); + private readonly hub = inject(AppHubService); + private readonly toast = inject(ToastService); + private initialLoad = true; + + readonly upgrades = signal([]); + readonly totalRecords = signal(0); + readonly currentPage = signal(1); + readonly pageSize = signal(50); + readonly loading = signal(false); + + readonly timeRange = signal('30'); + readonly selectedInstanceId = signal(''); + readonly instanceOptions = signal([]); + + readonly timeRangeOptions: SelectOption[] = [ + { label: 'Last 7 Days', value: '7' }, + { label: 'Last 30 Days', value: '30' }, + { label: 'Last 90 Days', value: '90' }, + { label: 'All Time', value: '0' }, + ]; + + constructor() { + effect(() => { + this.hub.cfScoresVersion(); + if (this.initialLoad) { + this.initialLoad = false; + return; + } + this.loadUpgrades(); + }); + } + + ngOnInit(): void { + this.loadInstances(); + this.loadUpgrades(); + } + + onTimeRangeChange(value: string): void { + this.timeRange.set(value); + this.currentPage.set(1); + this.loadUpgrades(); + } + + onInstanceFilterChange(value: string): void { + this.selectedInstanceId.set(value); + this.currentPage.set(1); + this.loadUpgrades(); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + this.loadUpgrades(); + } + + refresh(): void { + this.loadUpgrades(); + } + + itemTypeSeverity(itemType: string): 'info' | 'default' { + return itemType === 'Radarr' || itemType === 'Sonarr' ? 'info' : 'default'; + } + + private loadInstances(): void { + this.api.getInstances().subscribe({ + next: (result) => { + this.instanceOptions.set([ + { label: 'All Instances', value: '' }, + ...result.instances.map(i => ({ + label: `${i.name} (${i.itemType})`, + value: i.id, + })), + ]); + }, + error: () => this.toast.error('Failed to load instances'), + }); + } + + private loadUpgrades(): void { + this.loading.set(true); + const days = parseInt(this.timeRange(), 10) || undefined; + const instanceId = this.selectedInstanceId() || undefined; + + this.api.getRecentUpgrades(this.currentPage(), this.pageSize(), instanceId, days).subscribe({ + next: (result) => { + this.upgrades.set(result.items); + this.totalRecords.set(result.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.toast.error('Failed to load upgrades'); + }, + }); + } +} diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.ts b/code/frontend/src/app/features/settings/account/account-settings.component.ts index b0d354cd..3cc3815c 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.ts +++ b/code/frontend/src/app/features/settings/account/account-settings.component.ts @@ -19,7 +19,7 @@ import { QRCodeComponent } from 'angularx-qrcode'; standalone: true, imports: [ PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, - SpinnerComponent, AccordionComponent, ToggleComponent, + SpinnerComponent, ToggleComponent, EmptyStateComponent, LoadingStateComponent, QRCodeComponent, LabelComponent, ], templateUrl: './account-settings.component.html', diff --git a/code/frontend/src/app/features/settings/general/general-settings.component.html b/code/frontend/src/app/features/settings/general/general-settings.component.html index e048d3af..45120512 100644 --- a/code/frontend/src/app/features/settings/general/general-settings.component.html +++ b/code/frontend/src/app/features/settings/general/general-settings.component.html @@ -79,19 +79,6 @@
- -
- - -
-
-
(3); readonly httpTimeout = signal(30); readonly httpCertificateValidation = signal(CertificateValidationType.Enabled); - readonly searchEnabled = signal(true); - readonly searchDelay = signal(5); readonly statusCheckEnabled = signal(true); readonly ignoredDownloads = signal([]); readonly strikeInactivityWindowHours = signal(24); @@ -100,14 +98,6 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { return undefined; }); - readonly searchDelayError = computed(() => { - const v = this.searchDelay(); - if (v == null) return 'This field is required'; - if (v < 60) return 'Minimum value is 60'; - if (v > 300) return 'Maximum value is 300'; - return undefined; - }); - readonly logRollingSizeError = computed(() => { const v = this.logRollingSizeMB(); if (v == null) return 'This field is required'; @@ -160,7 +150,6 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { this.strikeInactivityWindowHoursError() || this.httpMaxRetriesError() || this.httpTimeoutError() || - this.searchDelayError() || this.logRollingSizeError() || this.logRetainedFileCountError() || this.logTimeLimitError() || @@ -182,8 +171,6 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { this.httpMaxRetries.set(config.httpMaxRetries); this.httpTimeout.set(config.httpTimeout); this.httpCertificateValidation.set(config.httpCertificateValidation); - this.searchEnabled.set(config.searchEnabled); - this.searchDelay.set(config.searchDelay); this.statusCheckEnabled.set(config.statusCheckEnabled); this.ignoredDownloads.set(config.ignoredDownloads ?? []); this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours); @@ -224,8 +211,6 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { httpMaxRetries: this.httpMaxRetries() ?? 3, httpTimeout: this.httpTimeout() ?? 30, httpCertificateValidation: this.httpCertificateValidation() as CertificateValidationType, - searchEnabled: this.searchEnabled(), - searchDelay: this.searchDelay() ?? 5, statusCheckEnabled: this.statusCheckEnabled(), strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24, ignoredDownloads: this.ignoredDownloads(), @@ -268,8 +253,6 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges { httpMaxRetries: this.httpMaxRetries(), httpTimeout: this.httpTimeout(), httpCertificateValidation: this.httpCertificateValidation(), - searchEnabled: this.searchEnabled(), - searchDelay: this.searchDelay(), statusCheckEnabled: this.statusCheckEnabled(), strikeInactivityWindowHours: this.strikeInactivityWindowHours(), ignoredDownloads: this.ignoredDownloads(), diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.html b/code/frontend/src/app/features/settings/notifications/notifications.component.html index 17350657..b0fa098c 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.html +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.html @@ -59,6 +59,9 @@ @if (provider.events.onCategoryChanged) { Category Changed } + @if (provider.events.onSearchTriggered) { + Search Triggered + }
} @empty { @@ -277,6 +280,9 @@ +
diff --git a/code/frontend/src/app/features/settings/notifications/notifications.component.ts b/code/frontend/src/app/features/settings/notifications/notifications.component.ts index d63bb5d9..24ae0f2b 100644 --- a/code/frontend/src/app/features/settings/notifications/notifications.component.ts +++ b/code/frontend/src/app/features/settings/notifications/notifications.component.ts @@ -186,6 +186,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { readonly onQueueItemDeleted = signal(true); readonly onDownloadCleaned = signal(true); readonly onCategoryChanged = signal(false); + readonly onSearchTriggered = signal(false); // Modal validation readonly modalNameError = computed(() => { @@ -408,6 +409,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { this.onQueueItemDeleted.set(provider.events.onQueueItemDeleted); this.onDownloadCleaned.set(provider.events.onDownloadCleaned); this.onCategoryChanged.set(provider.events.onCategoryChanged); + this.onSearchTriggered.set(provider.events.onSearchTriggered); this.modalVisible.set(true); } @@ -462,6 +464,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { this.onQueueItemDeleted.set(true); this.onDownloadCleaned.set(true); this.onCategoryChanged.set(false); + this.onSearchTriggered.set(false); } private getEventFlags() { @@ -472,6 +475,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges { onQueueItemDeleted: this.onQueueItemDeleted(), onDownloadCleaned: this.onDownloadCleaned(), onCategoryChanged: this.onCategoryChanged(), + onSearchTriggered: this.onSearchTriggered(), }; } diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.html b/code/frontend/src/app/features/settings/seeker/seeker.component.html new file mode 100644 index 00000000..6917d93e --- /dev/null +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.html @@ -0,0 +1,152 @@ + + +@if (loadError()) { + + + Retry + + +} @else if (loader.showSpinner()) { + +} @else if (!loader.loading()) { +
+ +
+ + @if (searchEnabled()) { + +

Using a very low interval will trigger searches more frequently, increasing indexer load significantly. This can result in rate limit warnings or a ban from your indexer, so be careful!

+ + } +
+
+ + @if (searchEnabled()) { + +
+ + @if (proactiveSearchEnabled()) { + + @if (strategyDescription()) { +

{{ strategyDescription() }}

+ } +

Replacement searches (triggered when an item is removed from an arr queue) always take priority over proactive searches, regardless of the selection strategy.

+
+ + + + + + + } +
+
+ + @if (proactiveSearchEnabled() && instances().length > 0) { + + @if (instanceError()) { +
{{ instanceError() }}
+ } +
+ @for (instance of instances(); track instance.arrInstanceId) { +
+
+
+ + {{ instance.instanceName }} + {{ instance.instanceType }} +
+ +
+ @if (!instance.arrInstanceEnabled) { +
+ Enable this instance in Arr Settings first +
+ } + @if (instance.enabled) { +
+ + + +
+ } + @if (instance.lastProcessedAt) { +
+ Last processed: {{ instance.lastProcessedAt | date:'medium' }} +
+ } +
+ } +
+
+ } + } + +
+ + {{ saved() ? 'Saved!' : 'Save Settings' }} + +
+
+} diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.scss b/code/frontend/src/app/features/settings/seeker/seeker.component.scss new file mode 100644 index 00000000..fdf0bc9d --- /dev/null +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.scss @@ -0,0 +1,93 @@ +@use 'settings-layout' as *; +@use 'glass' as *; + +:host { @include settings-page; } + +.settings-form { @include settings-form; } +.form-stack { @include form-stack; } +.form-row { @include form-row; } +.form-divider { @include form-divider; } +.form-actions { @include form-actions; } + +.strategy-hint { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: calc(-1 * var(--space-1)) 0 0; +} + +.interval-warning { + font-size: var(--font-size-sm); + color: var(--color-error); + margin: calc(-1 * var(--space-1)) 0 0; +} + +.priority-note { + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin: calc(-1 * var(--space-1)) 0 0; +} + +.instance-error { + font-size: var(--font-size-sm); + color: var(--color-error); + margin-bottom: var(--space-3); +} + +.instance-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.instance-row { + @include glass('subtle'); + padding: var(--space-4); + border-radius: var(--radius-md); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + } + + &__info { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + } + + &__icon { + width: 24px; + height: 24px; + flex-shrink: 0; + } + + &__name { + font-size: var(--font-size-md); + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__details { + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--glass-border); + } + + &__meta { + margin-top: var(--space-2); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__warning { + margin-top: var(--space-2); + font-size: var(--font-size-xs); + color: var(--color-warning); + } +} diff --git a/code/frontend/src/app/features/settings/seeker/seeker.component.ts b/code/frontend/src/app/features/settings/seeker/seeker.component.ts new file mode 100644 index 00000000..cc095770 --- /dev/null +++ b/code/frontend/src/app/features/settings/seeker/seeker.component.ts @@ -0,0 +1,278 @@ +import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { + CardComponent, ButtonComponent, ToggleComponent, + SelectComponent, ChipInputComponent, NumberInputComponent, + EmptyStateComponent, LoadingStateComponent, BadgeComponent, + type SelectOption, type BadgeSeverity, +} from '@ui'; +import { SeekerApi } from '@core/api/seeker.api'; +import { ToastService } from '@core/services/toast.service'; +import { ConfirmService } from '@core/services/confirm.service'; +import { UpdateSeekerConfig } from '@shared/models/seeker-config.model'; +import { HasPendingChanges } from '@core/guards/pending-changes.guard'; +import { ApiError } from '@core/interceptors/error.interceptor'; +import { DeferredLoader } from '@shared/utils/loading.util'; +import { SelectionStrategy } from '@shared/models/enums'; + +const INTERVAL_OPTIONS: SelectOption[] = [ + { label: '2 minutes', value: 2 }, + { label: '3 minutes', value: 3 }, + { label: '4 minutes', value: 4 }, + { label: '5 minutes', value: 5 }, + { label: '6 minutes', value: 6 }, + { label: '10 minutes', value: 10 }, + { label: '12 minutes', value: 12 }, + { label: '15 minutes', value: 15 }, + { label: '20 minutes', value: 20 }, + { label: '30 minutes', value: 30 }, +]; + +const STRATEGY_OPTIONS: SelectOption[] = [ + { label: 'Balanced Weighted', value: SelectionStrategy.BalancedWeighted }, + { label: 'Oldest Search First', value: SelectionStrategy.OldestSearchFirst }, + { label: 'Oldest Search Weighted', value: SelectionStrategy.OldestSearchWeighted }, + { label: 'Newest First', value: SelectionStrategy.NewestFirst }, + { label: 'Newest Weighted', value: SelectionStrategy.NewestWeighted }, + { label: 'Random', value: SelectionStrategy.Random }, +]; + +const STRATEGY_DESCRIPTIONS: Record = { + [SelectionStrategy.BalancedWeighted]: 'Prioritizes items that are both newly added and haven\'t been searched recently. Good default for most libraries.', + [SelectionStrategy.OldestSearchFirst]: 'Works through your library in order, starting with items that haven\'t been searched the longest. Guarantees every item gets covered.', + [SelectionStrategy.OldestSearchWeighted]: 'Favors items that haven\'t been searched recently, but still gives other items a chance.', + [SelectionStrategy.NewestFirst]: 'Always picks the most recently added items first. Best for keeping new additions up to date quickly.', + [SelectionStrategy.NewestWeighted]: 'Favors recently added items, but still gives older items a chance.', + [SelectionStrategy.Random]: 'Every item has an equal chance of being picked. No prioritization.', +}; + +interface InstanceState { + arrInstanceId: string; + instanceName: string; + instanceType: string; + enabled: boolean; + skipTags: string[]; + lastProcessedAt?: string; + arrInstanceEnabled: boolean; + activeDownloadLimit: number; + minCycleTimeDays: number; +} + +@Component({ + selector: 'app-seeker', + standalone: true, + imports: [ + PageHeaderComponent, CardComponent, ButtonComponent, + ToggleComponent, SelectComponent, ChipInputComponent, NumberInputComponent, + EmptyStateComponent, LoadingStateComponent, BadgeComponent, DatePipe, + ], + templateUrl: './seeker.component.html', + styleUrl: './seeker.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SeekerComponent implements OnInit, HasPendingChanges { + private readonly api = inject(SeekerApi); + private readonly toast = inject(ToastService); + private readonly confirm = inject(ConfirmService); + + private readonly savedSnapshot = signal(''); + + readonly intervalOptions = INTERVAL_OPTIONS; + readonly strategyOptions = STRATEGY_OPTIONS; + readonly loader = new DeferredLoader(); + readonly loadError = signal(false); + readonly saving = signal(false); + readonly saved = signal(false); + + readonly searchEnabled = signal(true); + readonly searchInterval = signal(2); + readonly proactiveSearchEnabled = signal(false); + readonly selectionStrategy = signal(SelectionStrategy.BalancedWeighted); + readonly monitoredOnly = signal(true); + readonly useCutoff = signal(false); + readonly useCustomFormatScore = signal(false); + readonly useRoundRobin = signal(true); + readonly postReleaseGraceHours = signal(6); + + readonly instances = signal([]); + + readonly strategyDescription = computed(() => STRATEGY_DESCRIPTIONS[this.selectionStrategy() as SelectionStrategy] ?? ''); + + readonly instanceError = computed(() => { + if (this.proactiveSearchEnabled() && this.instances().length > 0 && !this.instances().some(i => i.enabled)) { + return 'At least one instance must be enabled when proactive search is enabled'; + } + return undefined; + }); + + readonly hasErrors = computed(() => !!this.instanceError()); + + ngOnInit(): void { + this.loadConfig(); + } + + private loadConfig(): void { + this.loader.start(); + this.api.getConfig().subscribe({ + next: (config) => { + this.searchEnabled.set(config.searchEnabled); + this.searchInterval.set(config.searchInterval); + this.proactiveSearchEnabled.set(config.proactiveSearchEnabled); + this.selectionStrategy.set(config.selectionStrategy); + this.monitoredOnly.set(config.monitoredOnly); + this.useCutoff.set(config.useCutoff); + this.useCustomFormatScore.set(config.useCustomFormatScore); + this.useRoundRobin.set(config.useRoundRobin); + this.postReleaseGraceHours.set(config.postReleaseGraceHours); + this.instances.set(config.instances.map(i => ({ + arrInstanceId: i.arrInstanceId, + instanceName: i.instanceName, + instanceType: i.instanceType, + enabled: i.enabled, + skipTags: [...i.skipTags], + lastProcessedAt: i.lastProcessedAt, + arrInstanceEnabled: i.arrInstanceEnabled, + activeDownloadLimit: i.activeDownloadLimit, + minCycleTimeDays: i.minCycleTimeDays, + }))); + this.loader.stop(); + this.savedSnapshot.set(this.buildSnapshot()); + }, + error: () => { + this.toast.error('Failed to load seeker settings'); + this.loader.stop(); + this.loadError.set(true); + }, + }); + } + + retry(): void { + this.loadError.set(false); + this.loadConfig(); + } + + async toggleRoundRobin(newValue: boolean): Promise { + if (!newValue) { + const confirmed = await this.confirm.confirm({ + title: 'Disable Round Robin', + message: 'Disabling round robin will trigger a search for each enabled arr instance per run. This could result in too many requests to your indexers and potentially get you banned.', + confirmLabel: 'Disable', + destructive: true, + }); + if (!confirmed) { + // The toggle already flipped its internal state to false. + // Sync our signal to false first, then restore to true in the next microtask + // so Angular detects an actual change and pushes it back to the toggle. + this.useRoundRobin.set(false); + queueMicrotask(() => this.useRoundRobin.set(true)); + return; + } + } + this.useRoundRobin.set(newValue); + } + + toggleInstance(index: number): void { + this.instances.update(instances => { + const updated = [...instances]; + updated[index] = { ...updated[index], enabled: !updated[index].enabled }; + return updated; + }); + } + + updateInstanceSkipTags(index: number, tags: string[]): void { + this.instances.update(instances => { + const updated = [...instances]; + updated[index] = { ...updated[index], skipTags: tags }; + return updated; + }); + } + + updateInstanceActiveDownloadLimit(index: number, limit: number | null): void { + this.instances.update(instances => { + const updated = [...instances]; + updated[index] = { ...updated[index], activeDownloadLimit: limit ?? 3 }; + return updated; + }); + } + + updateInstanceMinCycleTimeDays(index: number, days: number | null): void { + this.instances.update(instances => { + const updated = [...instances]; + updated[index] = { ...updated[index], minCycleTimeDays: days ?? 7 }; + return updated; + }); + } + + getInstanceIcon(instanceType: string): string { + return `icons/ext/${instanceType.toLowerCase()}-light.svg`; + } + + getInstanceTypeSeverity(type: string): BadgeSeverity { + if (type === 'Radarr') return 'warning'; + if (type === 'Sonarr') return 'info'; + return 'default'; + } + + save(): void { + const config: UpdateSeekerConfig = { + searchEnabled: this.searchEnabled(), + searchInterval: (this.searchInterval() as number) ?? 2, + proactiveSearchEnabled: this.proactiveSearchEnabled(), + selectionStrategy: this.selectionStrategy() as SelectionStrategy, + monitoredOnly: this.monitoredOnly(), + useCutoff: this.useCutoff(), + useCustomFormatScore: this.useCustomFormatScore(), + useRoundRobin: this.useRoundRobin(), + postReleaseGraceHours: this.postReleaseGraceHours(), + instances: this.instances().map(i => ({ + arrInstanceId: i.arrInstanceId, + enabled: i.enabled, + skipTags: i.skipTags, + activeDownloadLimit: i.activeDownloadLimit, + minCycleTimeDays: i.minCycleTimeDays, + })), + }; + + this.saving.set(true); + this.api.updateConfig(config).subscribe({ + next: () => { + this.toast.success('Seeker settings saved'); + this.saving.set(false); + this.saved.set(true); + setTimeout(() => this.saved.set(false), 1500); + this.savedSnapshot.set(this.buildSnapshot()); + }, + error: (err: ApiError) => { + this.toast.error(err.statusCode === 400 + ? err.message + : 'Failed to save seeker settings'); + this.saving.set(false); + }, + }); + } + + private buildSnapshot(): string { + return JSON.stringify({ + searchEnabled: this.searchEnabled(), + searchInterval: this.searchInterval(), + proactiveSearchEnabled: this.proactiveSearchEnabled(), + selectionStrategy: this.selectionStrategy(), + monitoredOnly: this.monitoredOnly(), + useCutoff: this.useCutoff(), + useCustomFormatScore: this.useCustomFormatScore(), + useRoundRobin: this.useRoundRobin(), + postReleaseGraceHours: this.postReleaseGraceHours(), + instances: [...this.instances()].sort((a, b) => a.arrInstanceId.localeCompare(b.arrInstanceId)), + }); + } + + readonly dirty = computed(() => { + const saved = this.savedSnapshot(); + return saved !== '' && saved !== this.buildSnapshot(); + }); + + hasPendingChanges(): boolean { + return this.dirty(); + } +} diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts index 8dd4f89c..e382dcee 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts @@ -50,6 +50,7 @@ export class NavSidebarComponent { { label: 'Logs', icon: 'tablerFileText', route: '/logs' }, { label: 'Events', icon: 'tablerBell', route: '/events' }, { label: 'Strikes', icon: 'tablerBolt', route: '/strikes' }, + { label: 'Seeker Stats', icon: 'tablerChartDots', route: '/seeker-stats' }, ]; settingsItems: NavItem[] = [ @@ -58,6 +59,7 @@ export class NavSidebarComponent { { label: 'Malware Blocker', icon: 'tablerShieldLock', route: '/settings/malware-blocker' }, { label: 'Download Cleaner', icon: 'tablerTrash', route: '/settings/download-cleaner' }, { label: 'Blacklist Sync', icon: 'tablerBan', route: '/settings/blacklist-sync' }, + { label: 'Seeker', icon: 'tablerSearch', route: '/settings/seeker' }, ]; mediaAppItems: NavItem[] = [ diff --git a/code/frontend/src/app/shared/models/enums.ts b/code/frontend/src/app/shared/models/enums.ts index d2048900..ae7dc3f0 100644 --- a/code/frontend/src/app/shared/models/enums.ts +++ b/code/frontend/src/app/shared/models/enums.ts @@ -90,6 +90,29 @@ export enum JobType { MalwareBlocker = 'MalwareBlocker', DownloadCleaner = 'DownloadCleaner', BlacklistSynchronizer = 'BlacklistSynchronizer', + Seeker = 'Seeker', +} + +export enum SelectionStrategy { + BalancedWeighted = 'BalancedWeighted', + OldestSearchFirst = 'OldestSearchFirst', + OldestSearchWeighted = 'OldestSearchWeighted', + NewestFirst = 'NewestFirst', + NewestWeighted = 'NewestWeighted', + Random = 'Random', +} + +export enum SeriesSearchType { + Season = 'Season', + Series = 'Series', +} + +export enum SearchCommandStatus { + Pending = 'Pending', + Started = 'Started', + Completed = 'Completed', + Failed = 'Failed', + TimedOut = 'TimedOut', } export type ArrType = 'sonarr' | 'radarr' | 'lidarr' | 'readarr' | 'whisparr'; diff --git a/code/frontend/src/app/shared/models/general-config.model.ts b/code/frontend/src/app/shared/models/general-config.model.ts index 84058078..95cea8ce 100644 --- a/code/frontend/src/app/shared/models/general-config.model.ts +++ b/code/frontend/src/app/shared/models/general-config.model.ts @@ -22,8 +22,6 @@ export interface GeneralConfig { httpMaxRetries: number; httpTimeout: number; httpCertificateValidation: CertificateValidationType; - searchEnabled: boolean; - searchDelay: number; statusCheckEnabled: boolean; strikeInactivityWindowHours: number; log?: LoggingConfig; diff --git a/code/frontend/src/app/shared/models/notification-provider.model.ts b/code/frontend/src/app/shared/models/notification-provider.model.ts index fd04616e..2d582970 100644 --- a/code/frontend/src/app/shared/models/notification-provider.model.ts +++ b/code/frontend/src/app/shared/models/notification-provider.model.ts @@ -13,6 +13,7 @@ export interface NotificationEventFlags { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface NotificationProviderDto { @@ -46,6 +47,7 @@ export interface CreateNotifiarrProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreateAppriseProviderRequest { @@ -62,6 +64,7 @@ export interface CreateAppriseProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreateNtfyProviderRequest { @@ -81,6 +84,7 @@ export interface CreateNtfyProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreateTelegramProviderRequest { @@ -96,6 +100,7 @@ export interface CreateTelegramProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreateDiscordProviderRequest { @@ -110,6 +115,7 @@ export interface CreateDiscordProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreatePushoverProviderRequest { @@ -129,6 +135,7 @@ export interface CreatePushoverProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } export interface CreateGotifyProviderRequest { @@ -143,6 +150,7 @@ export interface CreateGotifyProviderRequest { onQueueItemDeleted: boolean; onDownloadCleaned: boolean; onCategoryChanged: boolean; + onSearchTriggered: boolean; } // Test request types (minimal, no event flags needed) diff --git a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts index 3bba404e..91f00687 100644 --- a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts +++ b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts @@ -1,16 +1,10 @@ -import { PatternMode, ScheduleUnit } from './enums'; +import { PatternMode } from './enums'; import { StallRule, SlowRule } from './queue-rule.model'; +import type { JobSchedule } from '@shared/utils/schedule.util'; -export interface JobSchedule { - every: number; - type: ScheduleUnit; -} - -export const ScheduleOptions: Record = { - [ScheduleUnit.Seconds]: [30], - [ScheduleUnit.Minutes]: [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30], - [ScheduleUnit.Hours]: [1, 2, 3, 4, 6], -}; +// Re-export for backward compatibility +export type { JobSchedule } from '@shared/utils/schedule.util'; +export { ScheduleOptions } from '@shared/utils/schedule.util'; export interface FailedImportConfig { maxStrikes: number; diff --git a/code/frontend/src/app/shared/models/seeker-config.model.ts b/code/frontend/src/app/shared/models/seeker-config.model.ts new file mode 100644 index 00000000..383ba933 --- /dev/null +++ b/code/frontend/src/app/shared/models/seeker-config.model.ts @@ -0,0 +1,47 @@ +import { SelectionStrategy } from './enums'; + +export interface SeekerConfig { + searchEnabled: boolean; + searchInterval: number; + proactiveSearchEnabled: boolean; + selectionStrategy: SelectionStrategy; + monitoredOnly: boolean; + useCutoff: boolean; + useCustomFormatScore: boolean; + useRoundRobin: boolean; + postReleaseGraceHours: number; + instances: SeekerInstanceConfig[]; +} + +export interface SeekerInstanceConfig { + arrInstanceId: string; + instanceName: string; + instanceType: string; + enabled: boolean; + skipTags: string[]; + lastProcessedAt?: string; + arrInstanceEnabled: boolean; + activeDownloadLimit: number; + minCycleTimeDays: number; +} + +export interface UpdateSeekerConfig { + searchEnabled: boolean; + searchInterval: number; + proactiveSearchEnabled: boolean; + selectionStrategy: SelectionStrategy; + monitoredOnly: boolean; + useCutoff: boolean; + useCustomFormatScore: boolean; + useRoundRobin: boolean; + postReleaseGraceHours: number; + instances: UpdateSeekerInstanceConfig[]; +} + +export interface UpdateSeekerInstanceConfig { + arrInstanceId: string; + enabled: boolean; + skipTags: string[]; + activeDownloadLimit: number; + minCycleTimeDays: number; +} diff --git a/code/frontend/src/app/shared/utils/schedule.util.ts b/code/frontend/src/app/shared/utils/schedule.util.ts index 4c9a6a55..95472138 100644 --- a/code/frontend/src/app/shared/utils/schedule.util.ts +++ b/code/frontend/src/app/shared/utils/schedule.util.ts @@ -1,5 +1,15 @@ import { ScheduleUnit } from '@shared/models/enums'; -import { JobSchedule } from '@shared/models/queue-cleaner-config.model'; + +export interface JobSchedule { + every: number; + type: ScheduleUnit; +} + +export const ScheduleOptions: Record = { + [ScheduleUnit.Seconds]: [30], + [ScheduleUnit.Minutes]: [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30], + [ScheduleUnit.Hours]: [1, 2, 3, 4, 6], +}; export function generateCronExpression(schedule: JobSchedule): string { const { every, type } = schedule; diff --git a/code/frontend/src/app/ui/index.ts b/code/frontend/src/app/ui/index.ts index 3dccfb6b..d95df325 100644 --- a/code/frontend/src/app/ui/index.ts +++ b/code/frontend/src/app/ui/index.ts @@ -25,3 +25,4 @@ export { ToastContainerComponent } from './toast-container/toast-container.compo export { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component'; export { SizeInputComponent } from './size-input/size-input.component'; export type { SizeUnit } from './size-input/size-input.component'; +export { TooltipComponent } from './tooltip/tooltip.component'; diff --git a/code/frontend/src/app/ui/input/input.component.html b/code/frontend/src/app/ui/input/input.component.html index 7d9e0b2b..67654b3b 100644 --- a/code/frontend/src/app/ui/input/input.component.html +++ b/code/frontend/src/app/ui/input/input.component.html @@ -20,6 +20,8 @@ [readOnly]="readonly()" [(ngModel)]="value" (blur)="blurred.emit($event)" + (keydown.enter)="entered.emit()" + (search)="onSearchCleared($event)" /> @if (hasEye()) {