diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs index 4369afc3..2abfe33b 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs @@ -36,90 +36,58 @@ public class SearchStatsControllerTests : IDisposable return JsonDocument.Parse(json).RootElement; } - #region ParseEventData (tested via GetEvents) + #region GetEvents with SearchEventData [Fact] - public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults() + public async Task GetEvents_WithNoSearchEventData_ReturnsUnknownDefaults() { - AddSearchEvent(data: null); + AddSearchEvent(); 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); + item.GetProperty("ItemTitle").GetString().ShouldBe("Unknown"); } [Fact] - public async Task GetEvents_WithValidFullJson_ParsesAllFields() + public async Task GetEvents_WithSearchEventData_ReturnsAllFields() { - 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 radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + + AddSearchEvent( + arrInstanceId: radarr.Id, + itemTitle: "Movie A", + searchType: SeekerSearchType.Proactive, + searchReason: SeekerSearchReason.Missing, + grabbedItems: ["Movie A (2024)"]); 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("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString()); + item.GetProperty("InstanceType").GetString().ShouldBe(nameof(InstanceType.Radarr)); + item.GetProperty("ItemTitle").GetString().ShouldBe("Movie A"); item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive)); + item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Missing)); + item.GetProperty("GrabbedItems")[0].GetString().ShouldBe("Movie A (2024)"); } [Fact] - public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields() + public async Task GetEvents_WithReplacementSearchType_ParsesCorrectEnum() { - // 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); + AddSearchEvent( + itemTitle: "Series A", + searchType: SeekerSearchType.Replacement, + searchReason: SeekerSearchReason.Replacement); var result = await _controller.GetEvents(); var body = GetResponseBody(result); var item = body.GetProperty("Items")[0]; item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement)); + item.GetProperty("SearchReason").GetString().ShouldBe(nameof(SeekerSearchReason.Replacement)); } #endregion @@ -127,23 +95,19 @@ public class SearchStatsControllerTests : IDisposable #region GetEvents Filtering [Fact] - public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl() + public async Task GetEvents_WithInstanceIdFilter_FiltersByArrInstanceId() { 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" })); + AddSearchEvent(arrInstanceId: radarr.Id, itemTitle: "Radarr Movie"); + AddSearchEvent(arrInstanceId: sonarr.Id, itemTitle: "Sonarr Series"); 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"); + body.GetProperty("Items")[0].GetProperty("ArrInstanceId").GetString().ShouldBe(radarr.Id.ToString()); } [Fact] @@ -152,21 +116,21 @@ public class SearchStatsControllerTests : IDisposable 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" })); + AddSearchEvent(cycleId: cycleA, itemTitle: "Cycle A Movie"); + AddSearchEvent(cycleId: cycleB, itemTitle: "Cycle B Movie"); 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"); + body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cycle A Movie"); } [Fact] - public async Task GetEvents_WithSearchFilter_FiltersOnDataField() + public async Task GetEvents_WithSearchFilter_FiltersOnItemTitle() { - AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } })); - AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } })); + AddSearchEvent(itemTitle: "The Matrix"); + AddSearchEvent(itemTitle: "Breaking Bad"); var result = await _controller.GetEvents(search: "matrix"); var body = GetResponseBody(result); @@ -179,14 +143,14 @@ public class SearchStatsControllerTests : IDisposable { for (int i = 0; i < 5; i++) { - AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" })); + AddSearchEvent(itemTitle: $"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("TotalPages").GetInt32().ShouldBe(3); body.GetProperty("Page").GetInt32().ShouldBe(2); body.GetProperty("Items").GetArrayLength().ShouldBe(2); } @@ -196,25 +160,40 @@ public class SearchStatsControllerTests : IDisposable #region Helpers private void AddSearchEvent( - string? data = null, - string? instanceUrl = null, - InstanceType? instanceType = null, + string? itemTitle = null, + SeekerSearchType searchType = SeekerSearchType.Proactive, + SeekerSearchReason searchReason = SeekerSearchReason.Missing, + List? grabbedItems = null, + Guid? arrInstanceId = null, Guid? cycleId = null, SearchCommandStatus? searchStatus = null) { - _eventsContext.Events.Add(new AppEvent + var appEvent = new AppEvent { EventType = EventType.SearchTriggered, Message = "Search triggered", Severity = EventSeverity.Information, - Data = data, - InstanceUrl = instanceUrl, - InstanceType = instanceType, + ArrInstanceId = arrInstanceId, CycleId = cycleId, SearchStatus = searchStatus, Timestamp = DateTime.UtcNow - }); + }; + + _eventsContext.Events.Add(appEvent); _eventsContext.SaveChanges(); + + if (itemTitle is not null) + { + _eventsContext.SearchEventData.Add(new SearchEventData + { + AppEventId = appEvent.Id, + ItemTitle = itemTitle, + SearchType = searchType, + SearchReason = searchReason, + GrabbedItems = grabbedItems ?? [], + }); + _eventsContext.SaveChanges(); + } } #endregion diff --git a/code/backend/Cleanuparr.Api/Controllers/EventsController.cs b/code/backend/Cleanuparr.Api/Controllers/EventsController.cs index f512e9d4..646d009c 100644 --- a/code/backend/Cleanuparr.Api/Controllers/EventsController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/EventsController.cs @@ -90,8 +90,6 @@ public class EventsController : ControllerBase EF.Functions.Like(e.Message, pattern) || EF.Functions.Like(e.Data, pattern) || EF.Functions.Like(e.TrackingId.ToString(), pattern) || - EF.Functions.Like(e.InstanceUrl, pattern) || - EF.Functions.Like(e.DownloadClientName, pattern) || EF.Functions.Like(e.JobRunId.ToString(), pattern) ); } diff --git a/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs b/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs index dbf0fb77..63adf63d 100644 --- a/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/ManualEventsController.cs @@ -68,9 +68,7 @@ public class ManualEventsController : ControllerBase string pattern = EventsContext.GetLikePattern(search); query = query.Where(e => EF.Functions.Like(e.Message, pattern) || - EF.Functions.Like(e.Data, pattern) || - EF.Functions.Like(e.InstanceUrl, pattern) || - EF.Functions.Like(e.DownloadClientName, pattern) + EF.Functions.Like(e.Data, pattern) ); } diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs index 05e95463..964fbdde 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs @@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Health; using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; -using Data.Models.Arr; using MassTransit; using Microsoft.Extensions.Caching.Memory; diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs index 318bee68..f852d520 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs @@ -6,14 +6,14 @@ public sealed record SearchEventResponse { public Guid Id { get; init; } public DateTime Timestamp { get; init; } - public string InstanceName { get; init; } = string.Empty; + public Guid? ArrInstanceId { get; init; } public string? InstanceType { get; init; } - public int ItemCount { get; init; } - public List Items { get; init; } = []; + public string ItemTitle { get; init; } = string.Empty; public SeekerSearchType SearchType { get; init; } + public SeekerSearchReason? SearchReason { get; init; } public SearchCommandStatus? SearchStatus { get; init; } public DateTime? CompletedAt { get; init; } - public object? GrabbedItems { get; init; } + public List GrabbedItems { get; init; } = []; public Guid? CycleId { get; init; } public bool IsDryRun { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index e1781384..e3d66873 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Cleanuparr.Api.Features.Seeker.Contracts.Responses; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; @@ -118,8 +117,7 @@ public sealed class SearchStatsController : ControllerBase } /// - /// Gets paginated search-triggered events with decoded data. - /// Supports optional text search across item names in event data. + /// Gets paginated search-triggered events /// [HttpGet("events")] public async Task GetEvents( @@ -135,20 +133,13 @@ public sealed class SearchStatsController : ControllerBase var query = _eventsContext.Events .AsNoTracking() + .Include(e => e.SearchEventData) .Where(e => e.EventType == EventType.SearchTriggered); - // Filter by instance URL if instanceId provided + // Filter by instance ID 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); - } + query = query.Where(e => e.ArrInstanceId == instanceId.Value); } // Filter by cycle ID @@ -157,10 +148,12 @@ public sealed class SearchStatsController : ControllerBase query = query.Where(e => e.CycleId == cycleId.Value); } - // Pre-filter by search term on the JSON data field + // Search by item title in SearchEventData if (!string.IsNullOrWhiteSpace(search)) { - query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower())); + string pattern = EventsContext.GetLikePattern(search); + query = query.Where(e => e.SearchEventData != null + && EF.Functions.Like(e.SearchEventData.ItemTitle, pattern)); } int totalCount = await query.CountAsync(); @@ -171,24 +164,37 @@ public sealed class SearchStatsController : ControllerBase .Take(pageSize) .ToListAsync(); - var items = rawEvents.Select(e => + // Resolve instance types from DataContext via ArrInstanceId + var arrInstanceIds = rawEvents + .Where(e => e.ArrInstanceId.HasValue) + .Select(e => e.ArrInstanceId!.Value) + .Distinct() + .ToList(); + + var instanceTypeMap = arrInstanceIds.Count > 0 + ? await _dataContext.ArrInstances + .AsNoTracking() + .Include(a => a.ArrConfig) + .Where(a => arrInstanceIds.Contains(a.Id)) + .ToDictionaryAsync(a => a.Id, a => a.ArrConfig.Type) + : new Dictionary(); + + var items = rawEvents.Select(e => new SearchEventResponse { - 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, - }; + Id = e.Id, + Timestamp = e.Timestamp, + ArrInstanceId = e.ArrInstanceId, + InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it) + ? it.ToString() + : null, + ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown", + SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive, + SearchReason = e.SearchEventData?.SearchReason, + SearchStatus = e.SearchStatus, + CompletedAt = e.CompletedAt, + GrabbedItems = e.SearchEventData?.GrabbedItems ?? [], + CycleId = e.CycleId, + IsDryRun = e.IsDryRun, }).ToList(); return Ok(new @@ -200,51 +206,4 @@ public sealed class SearchStatsController : ControllerBase 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.Domain/Entities/Arr/SearchItem.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs index d458da16..193a339c 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs @@ -1,4 +1,4 @@ -namespace Data.Models.Arr; +namespace Cleanuparr.Domain.Entities.Arr; public class SearchItem { diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesSearchItem.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesSearchItem.cs index 129dce33..df2e95ef 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesSearchItem.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SeriesSearchItem.cs @@ -1,5 +1,4 @@ using Cleanuparr.Domain.Enums; -using Data.Models.Arr; namespace Cleanuparr.Domain.Entities.Arr; diff --git a/code/backend/Cleanuparr.Domain/Enums/SeekerSearchReason.cs b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchReason.cs new file mode 100644 index 00000000..c01e2052 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchReason.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeekerSearchReason +{ + Missing, + QualityCutoffNotMet, + CustomFormatScoreBelowCutoff, + Replacement, +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs index 4f8932df..2d6cdd28 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Features.Context; @@ -6,12 +5,13 @@ using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence; -using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; -using Moq; +using NSubstitute; +using NSubstitute.ExceptionExtensions; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Events; @@ -19,11 +19,10 @@ namespace Cleanuparr.Infrastructure.Tests.Events; public class EventPublisherTests : IDisposable { private readonly EventsContext _context; - private readonly Mock> _hubContextMock; - private readonly Mock> _loggerMock; - private readonly Mock _notificationPublisherMock; - private readonly Mock _dryRunInterceptorMock; - private readonly Mock _clientProxyMock; + private readonly IHubContext _hubContext; + private readonly INotificationPublisher _notificationPublisher; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly IClientProxy _clientProxy; private readonly EventPublisher _publisher; public EventPublisherTests() @@ -31,30 +30,30 @@ public class EventPublisherTests : IDisposable // Setup in-memory database var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; _context = new EventsContext(options); // Setup mocks - _hubContextMock = new Mock>(); - _loggerMock = new Mock>(); - _notificationPublisherMock = new Mock(); - _dryRunInterceptorMock = new Mock(); - _clientProxyMock = new Mock(); + _hubContext = Substitute.For>(); + _notificationPublisher = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _clientProxy = Substitute.For(); // Setup HubContext to return client proxy - var clientsMock = new Mock(); - clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object); - _hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object); + var clients = Substitute.For(); + clients.All.Returns(_clientProxy); + _hubContext.Clients.Returns(clients); // Setup dry run interceptor to report dry run as disabled by default - _dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false); + _dryRunInterceptor.IsDryRunEnabled().Returns(false); _publisher = new EventPublisher( _context, - _hubContextMock.Object, - _loggerMock.Object, - _notificationPublisherMock.Object, - _dryRunInterceptorMock.Object); + _hubContext, + Substitute.For>(), + _notificationPublisher, + _dryRunInterceptor); // Setup JobRunId in context for tests ContextProvider.SetJobRunId(Guid.NewGuid()); @@ -137,10 +136,10 @@ public class EventPublisherTests : IDisposable await _publisher.PublishAsync(eventType, message, severity); // Assert - _clientProxyMock.Verify(c => c.SendCoreAsync( + await _clientProxy.Received(1).SendCoreAsync( "EventReceived", - It.Is(args => args.Length == 1 && args[0] is AppEvent), - It.IsAny()), Times.Once); + Arg.Is(args => args.Length == 1 && args[0] is AppEvent), + Arg.Any()); } [Fact] @@ -151,10 +150,10 @@ public class EventPublisherTests : IDisposable var message = "Test message"; var severity = EventSeverity.Important; - _clientProxyMock.Setup(c => c.SendCoreAsync( - It.IsAny(), - It.IsAny(), - It.IsAny())) + _clientProxy.SendCoreAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) .ThrowsAsync(new Exception("SignalR connection failed")); // Act - should not throw @@ -233,10 +232,10 @@ public class EventPublisherTests : IDisposable await _publisher.PublishManualAsync(message, severity); // Assert - _clientProxyMock.Verify(c => c.SendCoreAsync( + await _clientProxy.Received(1).SendCoreAsync( "ManualEventReceived", - It.Is(args => args.Length == 1 && args[0] is ManualEvent), - It.IsAny()), Times.Once); + Arg.Is(args => args.Length == 1 && args[0] is ManualEvent), + Arg.Any()); } #endregion @@ -255,7 +254,7 @@ public class EventPublisherTests : IDisposable await _publisher.PublishAsync(eventType, message, severity); // Assert - _dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once); + await _dryRunInterceptor.Received(1).IsDryRunEnabled(); } [Fact] @@ -269,14 +268,14 @@ public class EventPublisherTests : IDisposable await _publisher.PublishManualAsync(message, severity); // Assert - _dryRunInterceptorMock.Verify(d => d.IsDryRunEnabled(), Times.Once); + await _dryRunInterceptor.Received(1).IsDryRunEnabled(); } [Fact] public async Task PublishAsync_WhenDryRunEnabled_SetsIsDryRunTrue() { // Arrange - _dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true); + _dryRunInterceptor.IsDryRunEnabled().Returns(true); var eventType = EventType.StalledStrike; var message = "Dry run event"; var severity = EventSeverity.Warning; @@ -311,7 +310,7 @@ public class EventPublisherTests : IDisposable public async Task PublishManualAsync_WhenDryRunEnabled_SetsIsDryRunTrue() { // Arrange - _dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true); + _dryRunInterceptor.IsDryRunEnabled().Returns(true); var message = "Dry run manual event"; var severity = EventSeverity.Important; @@ -328,7 +327,7 @@ public class EventPublisherTests : IDisposable public async Task PublishAsync_WhenDryRunEnabled_StillSavesToDatabase() { // Arrange - _dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(true); + _dryRunInterceptor.IsDryRunEnabled().Returns(true); var eventType = EventType.StalledStrike; var message = "Should be saved"; var severity = EventSeverity.Warning; @@ -426,7 +425,7 @@ public class EventPublisherTests : IDisposable await _publisher.PublishQueueItemDeleted(removeFromClient: false, DeleteReason.FailedImport); // Assert - _notificationPublisherMock.Verify(n => n.NotifyQueueItemDeleted(false, DeleteReason.FailedImport), Times.Once); + await _notificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.FailedImport); } #endregion @@ -475,7 +474,7 @@ public class EventPublisherTests : IDisposable await _publisher.PublishDownloadCleaned(ratio, seedingTime, categoryName, reason); // Assert - _notificationPublisherMock.Verify(n => n.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason), Times.Once); + await _notificationPublisher.Received(1).NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason); } #endregion @@ -575,7 +574,7 @@ public class EventPublisherTests : IDisposable await _publisher.PublishCategoryChanged("old", "new", isTag: true); // Assert - _notificationPublisherMock.Verify(n => n.NotifyCategoryChanged("old", "new", true), Times.Once); + await _notificationPublisher.Received(1).NotifyCategoryChanged("old", "new", true); } #endregion @@ -586,7 +585,7 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchTriggered_SavesEventWithCorrectType() { // Act - await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive); + await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert var savedEvent = await _context.Events.FirstOrDefaultAsync(); @@ -599,7 +598,7 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchTriggered_SetsSearchStatusToPending() { // Act - await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert var savedEvent = await _context.Events.FirstOrDefaultAsync(); @@ -614,7 +613,7 @@ public class EventPublisherTests : IDisposable var cycleId = Guid.NewGuid(); // Act - await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleId); + await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing, cycleId); // Assert var savedEvent = await _context.Events.FirstOrDefaultAsync(); @@ -626,7 +625,7 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchTriggered_ReturnsEventId() { // Act - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert Assert.NotEqual(Guid.Empty, eventId); @@ -635,59 +634,56 @@ public class EventPublisherTests : IDisposable } [Fact] - public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData() + public async Task PublishSearchTriggered_CreatesSearchEventData() { // Act - await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement); + await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.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); + + var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == savedEvent.Id); + Assert.NotNull(searchData); + Assert.Equal("Series A", searchData.ItemTitle); + Assert.Equal(SeekerSearchType.Replacement, searchData.SearchType); + Assert.Equal(SeekerSearchReason.Replacement, searchData.SearchReason); } [Fact] public async Task PublishSearchTriggered_NotifiesSignalRClients() { // Act - await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert - _clientProxyMock.Verify(c => c.SendCoreAsync( + await _clientProxy.Received(1).SendCoreAsync( "EventReceived", - It.Is(args => args.Length == 1 && args[0] is AppEvent), - It.IsAny()), Times.Once); + Arg.Is(args => args.Length == 1 && args[0] is AppEvent), + Arg.Any()); } [Fact] public async Task PublishSearchTriggered_SendsNotification() { // Act - await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive); + await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert - _notificationPublisherMock.Verify( - n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny>()), - Times.Once); + await _notificationPublisher.Received(1).NotifySearchTriggered( + "Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); } [Fact] - public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items() + public async Task PublishSearchTriggered_IncludesItemTitleInMessage() { - // 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); + await _publisher.PublishSearchTriggered("The Matrix", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Assert var savedEvent = await _context.Events.FirstOrDefaultAsync(); Assert.NotNull(savedEvent); - Assert.Contains("+2 more", savedEvent.Message); + Assert.Contains("The Matrix", savedEvent.Message); } #endregion @@ -698,7 +694,7 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchCompleted_UpdatesEventStatus() { // Arrange — create a search event first - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Act await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); @@ -713,7 +709,7 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchCompleted_SetsCompletedAt() { // Arrange - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Act await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); @@ -725,42 +721,35 @@ public class EventPublisherTests : IDisposable } [Fact] - public async Task PublishSearchCompleted_MergesResultDataIntoExistingData() + public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData() { // Arrange - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); - var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } }; + var grabbedItems = new List { "Movie A (2024)" }; // Act - await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData); + await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, grabbedItems); // 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); + var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId); + Assert.NotNull(searchData); + Assert.Contains("Movie A (2024)", searchData.GrabbedItems); } [Fact] - public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData() + public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData() { // Arrange - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); - var originalEvent = await _context.Events.FindAsync(eventId); - string? originalData = originalEvent!.Data; + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Act await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); // Assert - var updatedEvent = await _context.Events.FindAsync(eventId); - Assert.NotNull(updatedEvent); - Assert.Equal(originalData, updatedEvent.Data); + var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId); + Assert.NotNull(searchData); + Assert.Empty(searchData.GrabbedItems); } [Fact] @@ -779,19 +768,19 @@ public class EventPublisherTests : IDisposable public async Task PublishSearchCompleted_NotifiesSignalRClients() { // Arrange - Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive); + Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing); // Reset mock to only capture the completion call - _clientProxyMock.Invocations.Clear(); + _clientProxy.ClearReceivedCalls(); // Act await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed); // Assert - _clientProxyMock.Verify(c => c.SendCoreAsync( + await _clientProxy.Received(1).SendCoreAsync( "EventReceived", - It.Is(args => args.Length == 1 && args[0] is AppEvent), - It.IsAny()), Times.Once); + Arg.Is(args => args.Length == 1 && args[0] is AppEvent), + Arg.Any()); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs index 9c1bfa5f..895d8e9e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs @@ -1,10 +1,10 @@ +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers; using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using MassTransit; using Microsoft.Extensions.Logging; using Moq; @@ -88,7 +88,7 @@ public class DownloadRemoverConsumerTests // Assert Assert.NotNull(capturedRequest); - Assert.Equal(request.InstanceType, capturedRequest.InstanceType); + Assert.Equal(request.Instance.ArrConfig.Type, capturedRequest.Instance.ArrConfig.Type); Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id); Assert.Equal(request.RemoveFromClient, capturedRequest.RemoveFromClient); Assert.Equal(request.DeleteReason, capturedRequest.DeleteReason); @@ -100,8 +100,7 @@ public class DownloadRemoverConsumerTests // Arrange var request = new QueueItemRemoveRequest { - InstanceType = InstanceType.Sonarr, - Instance = CreateArrInstance(), + Instance = CreateArrInstance(InstanceType.Sonarr), SearchItem = new SearchItem { Id = 456 }, Record = CreateQueueRecord(), RemoveFromClient = true, @@ -130,8 +129,7 @@ public class DownloadRemoverConsumerTests // Arrange var request = new QueueItemRemoveRequest { - InstanceType = InstanceType.Radarr, - Instance = CreateArrInstance(), + Instance = CreateArrInstance(InstanceType.Radarr), SearchItem = new SearchItem { Id = 789 }, Record = CreateQueueRecord(), RemoveFromClient = false, @@ -159,8 +157,7 @@ public class DownloadRemoverConsumerTests // Arrange var request = new QueueItemRemoveRequest { - InstanceType = InstanceType.Readarr, - Instance = CreateArrInstance(), + Instance = CreateArrInstance(InstanceType.Readarr), SearchItem = new SearchItem { Id = 111 }, Record = CreateQueueRecord(), RemoveFromClient = true, @@ -178,7 +175,7 @@ public class DownloadRemoverConsumerTests // Assert _queueItemRemoverMock.Verify(r => r.RemoveQueueItemAsync( - It.Is>(req => req.InstanceType == InstanceType.Readarr)), Times.Once); + It.Is>(req => req.Instance.ArrConfig.Type == InstanceType.Readarr)), Times.Once); } #endregion @@ -189,8 +186,7 @@ public class DownloadRemoverConsumerTests { return new QueueItemRemoveRequest { - InstanceType = InstanceType.Radarr, - Instance = CreateArrInstance(), + Instance = CreateArrInstance(InstanceType.Radarr), SearchItem = new SearchItem { Id = 123 }, Record = CreateQueueRecord(), RemoveFromClient = true, @@ -199,13 +195,14 @@ public class DownloadRemoverConsumerTests }; } - private static ArrInstance CreateArrInstance() + private static ArrInstance CreateArrInstance(InstanceType instanceType = InstanceType.Radarr) { return new ArrInstance { Name = "Test Instance", Url = new Uri("http://radarr.local"), - ApiKey = "test-api-key" + ApiKey = "test-api-key", + ArrConfig = new ArrConfig { Type = instanceType } }; } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index 8744a7b9..22838aad 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -1,4 +1,5 @@ using System.Net; +using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; @@ -13,23 +14,24 @@ using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Moq; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Shouldly; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover; public class QueueItemRemoverTests : IDisposable { - private readonly Mock> _loggerMock; + private readonly ILogger _logger; private readonly MemoryCache _memoryCache; - private readonly Mock _arrClientFactoryMock; - private readonly Mock _arrClientMock; + private readonly IArrClientFactory _arrClientFactory; + private readonly IArrClient _arrClient; private readonly EventPublisher _eventPublisher; private readonly EventsContext _eventsContext; private readonly DataContext _dataContext; @@ -38,14 +40,14 @@ public class QueueItemRemoverTests : IDisposable public QueueItemRemoverTests() { - _loggerMock = new Mock>(); + _logger = Substitute.For>(); _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - _arrClientFactoryMock = new Mock(); - _arrClientMock = new Mock(); + _arrClientFactory = Substitute.For(); + _arrClient = Substitute.For(); - _arrClientFactoryMock - .Setup(f => f.GetClient(It.IsAny(), It.IsAny())) - .Returns(_arrClientMock.Object); + _arrClientFactory + .GetClient(Arg.Any(), Arg.Any()) + .Returns(_arrClient); // Create real EventPublisher with mocked dependencies _eventsContext = TestEventsContextFactory.Create(); @@ -56,32 +58,32 @@ public class QueueItemRemoverTests : IDisposable _eventsContext.SaveChanges(); ContextProvider.SetJobRunId(_jobRunId); - var hubContextMock = new Mock>(); - var clientsMock = new Mock(); - clientsMock.Setup(c => c.All).Returns(Mock.Of()); - hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object); + var hubContext = Substitute.For>(); + var clients = Substitute.For(); + clients.All.Returns(Substitute.For()); + hubContext.Clients.Returns(clients); - var dryRunInterceptorMock = new Mock(); - dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false); + var dryRunInterceptor = Substitute.For(); + dryRunInterceptor.IsDryRunEnabled().Returns(false); // Setup interceptor for other uses (e.g., ArrClient deletion) - dryRunInterceptorMock - .Setup(d => d.InterceptAsync(It.IsAny(), It.IsAny())) + dryRunInterceptor + .InterceptAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); _eventPublisher = new EventPublisher( _eventsContext, - hubContextMock.Object, - Mock.Of>(), - Mock.Of(), - dryRunInterceptorMock.Object); + hubContext, + Substitute.For>(), + Substitute.For(), + dryRunInterceptor); // Create in-memory DataContext with seeded SeekerConfig _dataContext = TestDataContextFactory.Create(); _queueItemRemover = new QueueItemRemover( - _loggerMock.Object, + _logger, _memoryCache, - _arrClientFactoryMock.Object, + _arrClientFactory, _eventPublisher, _eventsContext, _dataContext @@ -107,23 +109,15 @@ public class QueueItemRemoverTests : IDisposable // Arrange 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 - _arrClientMock.Verify(c => c.DeleteQueueItemAsync( + await _arrClient.Received(1).DeleteQueueItemAsync( request.Instance, request.Record, request.RemoveFromClient, - request.DeleteReason), Times.Once); + request.DeleteReason); } [Fact] @@ -132,23 +126,15 @@ public class QueueItemRemoverTests : IDisposable // Arrange 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.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); + queueItems.Count.ShouldBe(1); + queueItems[0].ArrInstanceId.ShouldBe(request.Instance.Id); + queueItems[0].ItemId.ShouldBe(request.SearchItem.Id); + queueItems[0].Title.ShouldBe(request.Record.Title); } [Fact] @@ -159,19 +145,11 @@ public class QueueItemRemoverTests : IDisposable var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}"; _memoryCache.Set(cacheKey, true); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - Assert.False(_memoryCache.TryGetValue(cacheKey, out _)); + _memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse(); } [Theory] @@ -185,19 +163,11 @@ public class QueueItemRemoverTests : IDisposable // Arrange var request = CreateRemoveRequest(instanceType); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny()), Times.Once); + _arrClientFactory.Received(1).GetClient(instanceType, Arg.Any()); } #endregion @@ -212,20 +182,12 @@ public class QueueItemRemoverTests : IDisposable var hash = request.Record.DownloadId.ToLowerInvariant(); Striker.RecurringHashes.TryAdd(hash, null); - _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); + queueItems.ShouldBeEmpty(); } [Fact] @@ -236,19 +198,11 @@ public class QueueItemRemoverTests : IDisposable var hash = request.Record.DownloadId.ToLowerInvariant(); Striker.RecurringHashes.TryAdd(hash, null); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - Assert.False(Striker.RecurringHashes.ContainsKey(hash)); + Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse(); } [Fact] @@ -257,20 +211,12 @@ public class QueueItemRemoverTests : IDisposable // Arrange 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.Single(queueItems); + queueItems.Count.ShouldBe(1); } #endregion @@ -283,20 +229,12 @@ public class QueueItemRemoverTests : IDisposable // Arrange var request = CreateRemoveRequest(skipSearch: true); - _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); + queueItems.ShouldBeEmpty(); } [Fact] @@ -306,19 +244,11 @@ public class QueueItemRemoverTests : IDisposable var request = CreateRemoveRequest(skipSearch: true); var hash = request.Record.DownloadId.ToLowerInvariant(); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - hash was never in recurring, should still not be there - Assert.False(Striker.RecurringHashes.ContainsKey(hash)); + Striker.RecurringHashes.ContainsKey(hash).ShouldBeFalse(); } #endregion @@ -335,20 +265,12 @@ public class QueueItemRemoverTests : IDisposable 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); + queueItems.ShouldBeEmpty(); } #endregion @@ -361,20 +283,20 @@ public class QueueItemRemoverTests : IDisposable // Arrange var request = CreateRemoveRequest(); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + _arrClient + .DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); // Act & Assert - var exception = await Assert.ThrowsAsync( + var exception = await Should.ThrowAsync( () => _queueItemRemover.RemoveQueueItemAsync(request)); - Assert.Contains("might have already been deleted", exception.Message); - Assert.Contains(request.InstanceType.ToString(), exception.Message); + exception.Message.ShouldContain("might have already been deleted"); + exception.Message.ShouldContain(request.Instance.ArrConfig.Type.ToString()); } [Fact] @@ -385,20 +307,20 @@ public class QueueItemRemoverTests : IDisposable var cacheKey = $"remove_{request.Record.DownloadId.ToLowerInvariant()}_{request.Instance.Url}"; _memoryCache.Set(cacheKey, true); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + _arrClient + .DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); // Act & Assert - await Assert.ThrowsAsync( + await Should.ThrowAsync( () => _queueItemRemover.RemoveQueueItemAsync(request)); // Cache should be cleared in finally block - Assert.False(_memoryCache.TryGetValue(cacheKey, out _)); + _memoryCache.TryGetValue(cacheKey, out _).ShouldBeFalse(); } [Fact] @@ -408,19 +330,19 @@ public class QueueItemRemoverTests : IDisposable var request = CreateRemoveRequest(); var originalException = new HttpRequestException("Server error", null, HttpStatusCode.InternalServerError); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + _arrClient + .DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .ThrowsAsync(originalException); // Act & Assert - var exception = await Assert.ThrowsAsync( + var exception = await Should.ThrowAsync( () => _queueItemRemover.RemoveQueueItemAsync(request)); - Assert.Same(originalException, exception); + exception.ShouldBeSameAs(originalException); } [Fact] @@ -430,19 +352,19 @@ public class QueueItemRemoverTests : IDisposable var request = CreateRemoveRequest(); var originalException = new InvalidOperationException("Some other error"); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) + _arrClient + .DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .ThrowsAsync(originalException); // Act & Assert - var exception = await Assert.ThrowsAsync( + var exception = await Should.ThrowAsync( () => _queueItemRemover.RemoveQueueItemAsync(request)); - Assert.Same(originalException, exception); + exception.ShouldBeSameAs(originalException); } #endregion @@ -460,23 +382,15 @@ public class QueueItemRemoverTests : IDisposable // Arrange var request = CreateRemoveRequest(deleteReason: deleteReason); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _arrClientMock.Verify(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - deleteReason), Times.Once); + await _arrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + deleteReason); } [Theory] @@ -487,23 +401,15 @@ public class QueueItemRemoverTests : IDisposable // Arrange var request = CreateRemoveRequest(removeFromClient: removeFromClient); - _arrClientMock - .Setup(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(Task.CompletedTask); - // Act await _queueItemRemover.RemoveQueueItemAsync(request); // Assert - _arrClientMock.Verify(c => c.DeleteQueueItemAsync( - It.IsAny(), - It.IsAny(), + await _arrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), removeFromClient, - It.IsAny()), Times.Once); + Arg.Any()); } #endregion @@ -521,7 +427,6 @@ public class QueueItemRemoverTests : IDisposable return new QueueItemRemoveRequest { - InstanceType = instanceType, Instance = instance, SearchItem = new SearchItem { Id = 123 }, Record = CreateQueueRecord(), diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs new file mode 100644 index 00000000..81005e67 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs @@ -0,0 +1,351 @@ +using System.Text.Json; +using Cleanuparr.Domain.Entities; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.Jobs; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[Collection(IntegrationTestCollection.Name)] +public class DownloadCleanerIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + + public DownloadCleanerIntegrationTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + _fixture.Reset(); + } + + public void Dispose() + { + Striker.RecurringHashes.Clear(); + } + + private DownloadCleaner CreateSut() + { + return new DownloadCleaner( + Substitute.For>(), + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus, + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.DownloadServiceFactory, + _fixture.EventPublisher, + _fixture.TimeProvider, + _fixture.HardLinkFileService); + } + + /// + /// Creates a mock download service that uses the actual DB config (so seeding rules match by ID). + /// + private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig) + { + var mock = Substitute.For(); + mock.ClientConfig.Returns(dbConfig); + mock.LoginAsync().Returns(Task.CompletedTask); + return mock; + } + + [Fact] + public async Task ArrManagedDownloads_AreExcludedFromCleanup() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddSeedingRule(_fixture.DataContext); + + string arrManagedHash = "arr_managed_hash_123"; + string orphanedHash = "orphaned_hash_456"; + + var arrManagedDownload = CreateMockTorrentItem(arrManagedHash, "Managed.Show.S01E01"); + var orphanedDownload = CreateMockTorrentItem(orphanedHash, "Orphaned.Movie.2024"); + + var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient); + mockDownloadService.GetSeedingDownloads() + .Returns([arrManagedDownload, orphanedDownload]); + + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + // Setup arr queue iterator to return the arr-managed hash + var queueRecord = new QueueRecord + { + Id = 1, + Title = "Managed.Show.S01E01", + Protocol = "torrent", + DownloadId = arrManagedHash + }; + _fixture.SetupArrQueueIterator(queueRecord); + + var sut = CreateSut(); + + // Act - advance time past the 10s delay + var executeTask = sut.ExecuteAsync(); + _fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15)); + await executeTask; + + // Assert: Only the orphaned download should be passed to filter/clean + mockDownloadService.Received().FilterDownloadsToBeCleanedAsync( + Arg.Is>(list => + list.Count == 1 && list[0].Hash == orphanedHash), + Arg.Any>()); + } + + [Fact] + public async Task IgnoredDownloads_AreExcludedFromCleanup() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddSeedingRule(_fixture.DataContext); + + // Add a download name to the ignored list + var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync(); + generalConfig.IgnoredDownloads.Add("ignored_download"); + await _fixture.DataContext.SaveChangesAsync(); + + var ignoredDownload = CreateMockTorrentItem("some_hash", "ignored_download"); + var normalDownload = CreateMockTorrentItem("normal_hash", "Normal.Movie.2024"); + + var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient); + mockDownloadService.GetSeedingDownloads() + .Returns([ignoredDownload, normalDownload]); + + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + // No arr-managed downloads + _fixture.ArrQueueIterator.Iterate( + Arg.Any(), Arg.Any(), + Arg.Any, Task>>()) + .Returns(ci => + { + var callback = ci.Arg, Task>>(); + return callback(Array.Empty()); + }); + + var sut = CreateSut(); + + // Act + var executeTask = sut.ExecuteAsync(); + _fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15)); + await executeTask; + + // Assert: Only the non-ignored download should be processed + mockDownloadService.Received().FilterDownloadsToBeCleanedAsync( + Arg.Is>(list => + list.Count == 1 && list[0].Hash == "normal_hash"), + Arg.Any>()); + } + + [Fact] + public async Task NoDownloadClients_ExitsEarly() + { + // Arrange: No download clients configured (default seed has none) + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: No download service interactions, no events + _fixture.DownloadServiceFactory.DidNotReceive() + .GetDownloadService(Arg.Any()); + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.ShouldBeEmpty(); + } + + [Fact] + public async Task CleanedDownload_PublishesDownloadCleanedEvent() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddSeedingRule(_fixture.DataContext); + + var torrent = CreateMockTorrentItem("cleaned_hash_abc", "Completed.Movie.2024"); + + var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient); + mockDownloadService.GetSeedingDownloads().Returns([torrent]); + mockDownloadService.FilterDownloadsToBeCleanedAsync( + Arg.Any>(), Arg.Any>()) + .Returns(ci => ci.Arg>()); + + // Configure CleanDownloadsAsync to simulate what real DownloadService does: + // set ContextProvider keys and call real EventPublisher + mockDownloadService.CleanDownloadsAsync( + Arg.Any>(), Arg.Any>()) + .Returns(async ci => + { + ContextProvider.Set(ContextProvider.Keys.ItemName, "Completed.Movie.2024"); + ContextProvider.Set(ContextProvider.Keys.Hash, "cleaned_hash_abc"); + ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl); + ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id); + ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName); + ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name); + await _fixture.EventPublisher.PublishDownloadCleaned( + 1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached); + }); + + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + // No arr-managed downloads + _fixture.ArrQueueIterator.Iterate( + Arg.Any(), Arg.Any(), + Arg.Any, Task>>()) + .Returns(ci => + { + var callback = ci.Arg, Task>>(); + return callback(Array.Empty()); + }); + + var sut = CreateSut(); + + // Act + var executeTask = sut.ExecuteAsync(); + _fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15)); + await executeTask; + + // Assert: Full DownloadCleaned event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var cleanedEvent = events.First(e => e.EventType == EventType.DownloadCleaned); + cleanedEvent.Message.ShouldBe("Cleaned item from download client with reason: MaxRatioReached"); + cleanedEvent.Severity.ShouldBe(EventSeverity.Important); + cleanedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + cleanedEvent.ArrInstanceId.ShouldBeNull(); + cleanedEvent.DownloadClientId.ShouldBe(downloadClient.Id); + cleanedEvent.IsDryRun.ShouldBe(false); + cleanedEvent.StrikeId.ShouldBeNull(); + cleanedEvent.TrackingId.ShouldBeNull(); + cleanedEvent.SearchStatus.ShouldBeNull(); + cleanedEvent.CompletedAt.ShouldBeNull(); + cleanedEvent.CycleId.ShouldBeNull(); + cleanedEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(cleanedEvent.Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Completed.Movie.2024"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("cleaned_hash_abc"); + data.RootElement.GetProperty("categoryName").GetString().ShouldBe("completed"); + data.RootElement.GetProperty("ratio").GetDouble().ShouldBe(1.5); + data.RootElement.GetProperty("seedingTime").GetDouble().ShouldBe(24.0); + data.RootElement.GetProperty("reason").GetString().ShouldBe("MaxRatioReached"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1) + .NotifyDownloadCleaned(1.5, TimeSpan.FromHours(24), "completed", CleanReason.MaxRatioReached); + } + + [Fact] + public async Task UnlinkedDownload_PublishesCategoryChangedEvent() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddUnlinkedConfig(_fixture.DataContext, + enabled: true, targetCategory: "unlinked", categories: ["completed"]); + + var torrent = CreateMockTorrentItem("unlinked_hash_xyz", "NoLinks.Movie.2024", category: "completed"); + + var mockDownloadService = CreateMockDownloadServiceWithDbConfig(downloadClient); + mockDownloadService.GetSeedingDownloads().Returns([torrent]); + mockDownloadService.FilterDownloadsToChangeCategoryAsync( + Arg.Any>(), Arg.Any()) + .Returns(ci => ci.Arg>()); + + // Configure ChangeCategoryForNoHardLinksAsync to simulate what real DownloadService does + mockDownloadService.ChangeCategoryForNoHardLinksAsync( + Arg.Any>(), Arg.Any()) + .Returns(async ci => + { + ContextProvider.Set(ContextProvider.Keys.ItemName, "NoLinks.Movie.2024"); + ContextProvider.Set(ContextProvider.Keys.Hash, "unlinked_hash_xyz"); + ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, downloadClient.ExternalOrInternalUrl); + ContextProvider.Set(ContextProvider.Keys.DownloadClientId, downloadClient.Id); + ContextProvider.Set(ContextProvider.Keys.DownloadClientType, downloadClient.TypeName); + ContextProvider.Set(ContextProvider.Keys.DownloadClientName, downloadClient.Name); + await _fixture.EventPublisher.PublishCategoryChanged("completed", "unlinked"); + }); + + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + // No arr-managed downloads + _fixture.ArrQueueIterator.Iterate( + Arg.Any(), Arg.Any(), + Arg.Any, Task>>()) + .Returns(ci => + { + var callback = ci.Arg, Task>>(); + return callback(Array.Empty()); + }); + + var sut = CreateSut(); + + // Act + var executeTask = sut.ExecuteAsync(); + _fixture.TimeProvider.Advance(TimeSpan.FromSeconds(15)); + await executeTask; + + // Assert: Full CategoryChanged event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var categoryEvent = events.First(e => e.EventType == EventType.CategoryChanged); + categoryEvent.Message.ShouldBe("Category changed from 'completed' to 'unlinked'"); + categoryEvent.Severity.ShouldBe(EventSeverity.Information); + categoryEvent.JobRunId.ShouldBe(_fixture.JobRunId); + categoryEvent.ArrInstanceId.ShouldBeNull(); + categoryEvent.DownloadClientId.ShouldBe(downloadClient.Id); + categoryEvent.IsDryRun.ShouldBe(false); + categoryEvent.StrikeId.ShouldBeNull(); + categoryEvent.TrackingId.ShouldBeNull(); + categoryEvent.SearchStatus.ShouldBeNull(); + categoryEvent.CompletedAt.ShouldBeNull(); + categoryEvent.CycleId.ShouldBeNull(); + categoryEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(categoryEvent.Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("NoLinks.Movie.2024"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("unlinked_hash_xyz"); + data.RootElement.GetProperty("oldCategory").GetString().ShouldBe("completed"); + data.RootElement.GetProperty("newCategory").GetString().ShouldBe("unlinked"); + data.RootElement.GetProperty("isTag").GetBoolean().ShouldBe(false); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1) + .NotifyCategoryChanged("completed", "unlinked", false); + } + + private static ITorrentItemWrapper CreateMockTorrentItem(string hash, string name, string? category = null) + { + var mock = Substitute.For(); + mock.Hash.Returns(hash); + mock.Name.Returns(name); + mock.Category.Returns(category); + mock.IsIgnored(Arg.Any>()).Returns(ci => + { + var ignoredList = ci.Arg>(); + return ignoredList.Contains(name, StringComparer.InvariantCultureIgnoreCase); + }); + return mock; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestCollection.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestCollection.cs new file mode 100644 index 00000000..05a0888d --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[CollectionDefinition(Name)] +public class IntegrationTestCollection : ICollectionFixture +{ + public const string Name = "JobHandlerIntegration"; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs new file mode 100644 index 00000000..dc1cfb1d --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs @@ -0,0 +1,279 @@ +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.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.DownloadRemover; +using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; +using Cleanuparr.Infrastructure.Features.Files; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Features.Notifications; +using Cleanuparr.Infrastructure.Hubs; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.State; +using MassTransit; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +/// +/// Shared fixture for integration tests that wires up real services (EventPublisher, QueueItemRemover) +/// with NSubstitute mocks at external boundaries (Arr clients, download clients, notifications). +/// +public class IntegrationTestFixture : IDisposable +{ + // Real services + public DataContext DataContext { get; private set; } + public EventsContext EventsContext { get; private set; } + public MemoryCache Cache { get; private set; } + public EventPublisher EventPublisher { get; private set; } = null!; + public QueueItemRemover QueueItemRemover { get; private set; } = null!; + public Striker Striker { get; private set; } = null!; + public FakeTimeProvider TimeProvider { get; private set; } + + // Mocks + public IBus MessageBus { get; private set; } + public IArrClientFactory ArrClientFactory { get; private set; } + public IArrClient ArrClient { get; private set; } + public IArrQueueIterator ArrQueueIterator { get; private set; } + public IDownloadServiceFactory DownloadServiceFactory { get; private set; } + public IBlocklistProvider BlocklistProvider { get; private set; } + public IHardLinkFileService HardLinkFileService { get; private set; } + public INotificationPublisher NotificationPublisher { get; private set; } + public IDryRunInterceptor DryRunInterceptor { get; private set; } + public IEventPublisher EventPublisherInterface { get; private set; } = null!; + public IHubContext HubContext { get; private set; } + + // State + public Guid JobRunId { get; private set; } + public List CapturedMessages { get; } = []; + + public IntegrationTestFixture() + { + DataContext = TestDataContextFactory.Create(); + EventsContext = TestEventsContextFactory.Create(); + Cache = new MemoryCache(new MemoryCacheOptions()); + TimeProvider = new FakeTimeProvider(); + + MessageBus = Substitute.For(); + ArrClientFactory = Substitute.For(); + ArrClient = Substitute.For(); + ArrQueueIterator = Substitute.For(); + DownloadServiceFactory = Substitute.For(); + BlocklistProvider = Substitute.For(); + HardLinkFileService = Substitute.For(); + NotificationPublisher = Substitute.For(); + DryRunInterceptor = Substitute.For(); + HubContext = CreateMockHubContext(); + + SetupDefaults(); + BuildRealServices(); + } + + private void SetupDefaults() + { + // ArrClientFactory returns the shared ArrClient mock by default + ArrClientFactory.GetClient(Arg.Any(), Arg.Any()).Returns(ArrClient); + + // DryRunInterceptor returns false (not dry run) by default + DryRunInterceptor.IsDryRunEnabled().Returns(false); + DryRunInterceptor.InterceptAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + + // Capture messages published to IBus (generic Publish overloads) + MessageBus.Publish(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(ci => CapturedMessages.Add(ci[0])); + MessageBus.Publish(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(ci => CapturedMessages.Add(ci[0])); + + // Seed a JobRun so EventPublisher FK constraints are satisfied + JobRunId = Guid.NewGuid(); + EventsContext.JobRuns.Add(new JobRun { Id = JobRunId, Type = JobType.QueueCleaner }); + EventsContext.SaveChanges(); + ContextProvider.SetJobRunId(JobRunId); + } + + private void BuildRealServices() + { + EventPublisher = new EventPublisher( + EventsContext, + HubContext, + Substitute.For>(), + NotificationPublisher, + DryRunInterceptor); + + // Expose EventPublisher as both concrete and interface + EventPublisherInterface = EventPublisher; + + Striker = new Striker( + Substitute.For>(), + EventsContext, + EventPublisher, + DryRunInterceptor); + + QueueItemRemover = new QueueItemRemover( + Substitute.For>(), + Cache, + ArrClientFactory, + EventPublisher, + EventsContext, + DataContext); + } + + /// + /// Gets distinct remove requests from captured messages (NSubstitute may capture duplicates + /// when both generic type setups match). + /// + public List GetCapturedRemoveRequests() + { + return CapturedMessages + .Where(m => m is QueueItemRemoveRequest or QueueItemRemoveRequest) + .DistinctBy(m => m switch + { + QueueItemRemoveRequest r => r.Record.DownloadId, + QueueItemRemoveRequest r => r.Record.DownloadId, + _ => "" + }) + .ToList(); + } + + /// + /// Processes all captured IBus messages through the real QueueItemRemover pipeline. + /// This simulates what MassTransit consumers would do. Deduplicates to handle + /// NSubstitute's generic type matching behavior. + /// + public async Task ProcessCapturedRemoveRequestsAsync() + { + foreach (var message in GetCapturedRemoveRequests()) + { + switch (message) + { + case QueueItemRemoveRequest request: + await QueueItemRemover.RemoveQueueItemAsync(request); + break; + case QueueItemRemoveRequest request: + await QueueItemRemover.RemoveQueueItemAsync(request); + break; + } + } + } + + /// + /// Configures the IArrQueueIterator to invoke the callback with the given records + /// when Iterate is called for any instance. + /// + public void SetupArrQueueIterator(params QueueRecord[] records) + { + ArrQueueIterator.Iterate( + Arg.Any(), + Arg.Any(), + Arg.Any, Task>>()) + .Returns(ci => + { + var callback = ci.Arg, Task>>(); + return callback(records); + }); + } + + /// + /// Creates a NSubstitute IDownloadService mock with default configuration. + /// + public IDownloadService CreateMockDownloadService( + string clientName = "Test qBittorrent", + DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent, + DownloadClientType type = DownloadClientType.Torrent) + { + var mock = Substitute.For(); + mock.ClientConfig.Returns(new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = clientName, + TypeName = typeName, + Type = type, + Enabled = true, + Host = new Uri("http://localhost:8080"), + Username = "admin", + Password = "admin" + }); + mock.LoginAsync().Returns(Task.CompletedTask); + return mock; + } + + /// + /// Registers mock download services with the factory, matched by their ClientConfig. + /// + public void SetupDownloadServices(params IDownloadService[] services) + { + foreach (var service in services) + { + DownloadServiceFactory.GetDownloadService(service.ClientConfig).Returns(service); + } + } + + /// + /// Recreates DataContext, EventsContext, cache, and resets all mocks for a clean test. + /// + public void Reset() + { + DataContext?.Dispose(); + EventsContext?.Dispose(); + Cache?.Dispose(); + + DataContext = TestDataContextFactory.Create(); + EventsContext = TestEventsContextFactory.Create(); + Cache = new MemoryCache(new MemoryCacheOptions()); + TimeProvider = new FakeTimeProvider(); + CapturedMessages.Clear(); + + // Recreate all NSubstitute mocks to clear received call state + MessageBus = Substitute.For(); + ArrClientFactory = Substitute.For(); + ArrClient = Substitute.For(); + ArrQueueIterator = Substitute.For(); + DownloadServiceFactory = Substitute.For(); + BlocklistProvider = Substitute.For(); + HardLinkFileService = Substitute.For(); + NotificationPublisher = Substitute.For(); + DryRunInterceptor = Substitute.For(); + HubContext = CreateMockHubContext(); + + // Re-setup defaults and rebuild real services + SetupDefaults(); + BuildRealServices(); + + // Clear static state + Striker.RecurringHashes.Clear(); + } + + private static IHubContext CreateMockHubContext() + { + var hubContext = Substitute.For>(); + var clients = Substitute.For(); + var clientProxy = Substitute.For(); + clients.All.Returns(clientProxy); + hubContext.Clients.Returns(clients); + return hubContext; + } + + public void Dispose() + { + DataContext?.Dispose(); + EventsContext?.Dispose(); + Cache?.Dispose(); + Striker.RecurringHashes.Clear(); + GC.SuppressFinalize(this); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs new file mode 100644 index 00000000..3c3488b6 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs @@ -0,0 +1,268 @@ +using System.Text.Json; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Features.MalwareBlocker; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; +using MalwareBlockerJob = Cleanuparr.Infrastructure.Features.Jobs.MalwareBlocker; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[Collection(IntegrationTestCollection.Name)] +public class MalwareBlockerIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + + public MalwareBlockerIntegrationTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + _fixture.Reset(); + } + + public void Dispose() + { + Striker.RecurringHashes.Clear(); + } + + private MalwareBlockerJob CreateSut() + { + return new MalwareBlockerJob( + Substitute.For>(), + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus, + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.DownloadServiceFactory, + _fixture.BlocklistProvider, + _fixture.EventPublisher); + } + + [Fact] + public async Task MalwareDetected_RemovesFromArr_SavesEvent_SendsNotification() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + // Enable Radarr blocklist + var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync(); + contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true }; + await _fixture.DataContext.SaveChangesAsync(); + + var record = CreateQueueRecord(movieId: 77); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService.BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) + .Returns(new BlockFilesResult + { + Found = true, + ShouldRemove = true, + DeleteReason = DeleteReason.AllFilesBlocked, + IsPrivate = false + }); + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert Phase 1: Remove request was published + var removeRequests = _fixture.GetCapturedRemoveRequests(); + removeRequests.Count.ShouldBe(1); + + // Process through real QueueItemRemover + _fixture.ArrClient.DeleteQueueItemAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + await _fixture.ProcessCapturedRemoveRequestsAsync(); + + // Assert: Arr client was told to delete with AllFilesBlocked reason + await _fixture.ArrClient.Received(1).DeleteQueueItemAsync( + Arg.Is(i => i.Id == instance.Id), + Arg.Is(r => r.DownloadId == record.DownloadId), + true, + DeleteReason.AllFilesBlocked); + + // Assert: Full event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(2); + + // DownloadMarkedForDeletion event + var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion); + markedEvent.Message.ShouldBe("Download marked for deletion"); + markedEvent.Severity.ShouldBe(EventSeverity.Important); + markedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + markedEvent.ArrInstanceId.ShouldBe(instance.Id); + markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id); + markedEvent.IsDryRun.ShouldBe(false); + markedEvent.StrikeId.ShouldBeNull(); + markedEvent.TrackingId.ShouldBeNull(); + markedEvent.SearchStatus.ShouldBeNull(); + markedEvent.CompletedAt.ShouldBeNull(); + markedEvent.CycleId.ShouldBeNull(); + markedEvent.Data.ShouldNotBeNull(); + using (var markedData = JsonDocument.Parse(markedEvent.Data!)) + { + markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p"); + markedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789"); + } + + // QueueItemDeleted event + var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted); + deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked"); + deletedEvent.Severity.ShouldBe(EventSeverity.Important); + deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + deletedEvent.ArrInstanceId.ShouldBe(instance.Id); + deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id); + deletedEvent.IsDryRun.ShouldBe(false); + deletedEvent.StrikeId.ShouldBeNull(); + deletedEvent.TrackingId.ShouldBeNull(); + deletedEvent.SearchStatus.ShouldBeNull(); + deletedEvent.CompletedAt.ShouldBeNull(); + deletedEvent.CycleId.ShouldBeNull(); + deletedEvent.Data.ShouldNotBeNull(); + using (var deletedData = JsonDocument.Parse(deletedEvent.Data!)) + { + deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p"); + deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789"); + deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true); + deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1) + .NotifyQueueItemDeleted(true, DeleteReason.AllFilesBlocked); + + // Assert: Search queue item added for replacement search + var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync(); + searchItems.Count.ShouldBe(1); + searchItems[0].ItemId.ShouldBe(77); + } + + [Fact] + public async Task NoBlocklistsEnabled_ExitsEarly_NothingProcessed() + { + // Arrange: Default seed data has all blocklists disabled + TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: Blocklists were never loaded, no processing happened + await _fixture.BlocklistProvider.DidNotReceive().LoadBlocklistsAsync(); + _fixture.GetCapturedRemoveRequests().ShouldBeEmpty(); + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.ShouldBeEmpty(); + } + + [Fact] + public async Task PrivateTorrent_DeletePrivateFalse_RemoveFromClientIsFalse() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + // Enable Radarr blocklist, but keep DeletePrivate = false (default) + var contentBlockerConfig = await _fixture.DataContext.ContentBlockerConfigs.FirstAsync(); + contentBlockerConfig.Radarr = new BlocklistSettings { Enabled = true }; + contentBlockerConfig.DeletePrivate = false; + await _fixture.DataContext.SaveChangesAsync(); + + var record = CreateQueueRecord(movieId: 88); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService.BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) + .Returns(new BlockFilesResult + { + Found = true, + ShouldRemove = true, + DeleteReason = DeleteReason.AllFilesBlocked, + IsPrivate = true + }); + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: Remove request has RemoveFromClient = false + _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); + + _fixture.ArrClient.DeleteQueueItemAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + await _fixture.ProcessCapturedRemoveRequestsAsync(); + + await _fixture.ArrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + false, + DeleteReason.AllFilesBlocked); + + // Full event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted); + deletedEvent.Message.ShouldBe("Deleting item from queue with reason: AllFilesBlocked"); + deletedEvent.Severity.ShouldBe(EventSeverity.Important); + deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + deletedEvent.ArrInstanceId.ShouldBe(instance.Id); + deletedEvent.IsDryRun.ShouldBe(false); + deletedEvent.StrikeId.ShouldBeNull(); + deletedEvent.SearchStatus.ShouldBeNull(); + deletedEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(deletedEvent.Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789"); + data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false); + data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked"); + } + + await _fixture.NotificationPublisher.Received(1) + .NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked); + } + + private static QueueRecord CreateQueueRecord( + long movieId = 1, + string downloadId = "MALWARE_HASH_789", + string title = "Suspicious.Movie.2024.1080p") + { + return new QueueRecord + { + Id = 1, + Title = title, + Protocol = "torrent", + DownloadId = downloadId, + MovieId = movieId, + Status = "warning", + StatusMessages = [] + }; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs new file mode 100644 index 00000000..48402512 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs @@ -0,0 +1,332 @@ +using System.Text.Json; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; +using QueueCleanerJob = Cleanuparr.Infrastructure.Features.Jobs.QueueCleaner; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[Collection(IntegrationTestCollection.Name)] +public class QueueCleanerIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + + public QueueCleanerIntegrationTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + _fixture.Reset(); + } + + public void Dispose() + { + Striker.RecurringHashes.Clear(); + } + + private QueueCleanerJob CreateSut() + { + return new QueueCleanerJob( + Substitute.For>(), + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus, + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.DownloadServiceFactory, + _fixture.EventPublisher); + } + + [Fact] + public async Task StalledTorrent_RemovesFromArr_SavesEvent_SendsNotification_AddsToSearchQueue() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddStallRule(_fixture.DataContext); + + var record = CreateQueueRecord(movieId: 42); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) + .Returns(new DownloadCheckResult + { + ShouldRemove = true, + Found = true, + DeleteReason = DeleteReason.Stalled, + IsPrivate = false + }); + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert Phase 1: IBus received a remove request + var removeRequests = _fixture.GetCapturedRemoveRequests(); + removeRequests.Count.ShouldBe(1); + + // Process the captured messages through the real QueueItemRemover pipeline + _fixture.ArrClient.DeleteQueueItemAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + await _fixture.ProcessCapturedRemoveRequestsAsync(); + + // Assert Phase 2: Arr client was told to delete the item + await _fixture.ArrClient.Received(1).DeleteQueueItemAsync( + Arg.Is(i => i.Id == instance.Id), + Arg.Is(r => r.DownloadId == record.DownloadId), + true, + DeleteReason.Stalled); + + // Assert Phase 3: Events persisted with full property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(2); + + // DownloadMarkedForDeletion event + var markedEvent = events.First(e => e.EventType == EventType.DownloadMarkedForDeletion); + markedEvent.Message.ShouldBe("Download marked for deletion"); + markedEvent.Severity.ShouldBe(EventSeverity.Important); + markedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + markedEvent.ArrInstanceId.ShouldBe(instance.Id); + markedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id); + markedEvent.IsDryRun.ShouldBe(false); + markedEvent.StrikeId.ShouldBeNull(); + markedEvent.TrackingId.ShouldBeNull(); + markedEvent.SearchStatus.ShouldBeNull(); + markedEvent.CompletedAt.ShouldBeNull(); + markedEvent.CycleId.ShouldBeNull(); + markedEvent.Data.ShouldNotBeNull(); + using (var markedData = JsonDocument.Parse(markedEvent.Data!)) + { + markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p"); + markedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456"); + } + + // QueueItemDeleted event + var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted); + deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled"); + deletedEvent.Severity.ShouldBe(EventSeverity.Important); + deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + deletedEvent.ArrInstanceId.ShouldBe(instance.Id); + deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id); + deletedEvent.IsDryRun.ShouldBe(false); + deletedEvent.StrikeId.ShouldBeNull(); + deletedEvent.TrackingId.ShouldBeNull(); + deletedEvent.SearchStatus.ShouldBeNull(); + deletedEvent.CompletedAt.ShouldBeNull(); + deletedEvent.CycleId.ShouldBeNull(); + deletedEvent.Data.ShouldNotBeNull(); + using (var deletedData = JsonDocument.Parse(deletedEvent.Data!)) + { + deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p"); + deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456"); + deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true); + deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled"); + } + + // Assert Phase 4: Notification was triggered + await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.Stalled); + + // Assert Phase 5: Replacement search item was added to SearchQueue + var searchItems = await _fixture.DataContext.SearchQueue.ToListAsync(); + searchItems.Count.ShouldBe(1); + searchItems[0].ArrInstanceId.ShouldBe(instance.Id); + searchItems[0].ItemId.ShouldBe(42); + } + + [Fact] + public async Task FailedImport_RemovesWithFailedImportReason_SendsNotification() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var downloadClient = TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var record = CreateQueueRecord(movieId: 99); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + _fixture.ArrClient.ShouldRemoveFromQueue( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) + .Returns(new DownloadCheckResult + { + ShouldRemove = false, + Found = true, + IsPrivate = false + }); + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: failed import removal published + _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); + + _fixture.ArrClient.DeleteQueueItemAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + await _fixture.ProcessCapturedRemoveRequestsAsync(); + + // Full event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted); + deletedEvent.Message.ShouldBe("Deleting item from queue with reason: FailedImport"); + deletedEvent.Severity.ShouldBe(EventSeverity.Important); + deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + deletedEvent.ArrInstanceId.ShouldBe(instance.Id); + deletedEvent.DownloadClientId.ShouldBe(mockDownloadService.ClientConfig.Id); + deletedEvent.IsDryRun.ShouldBe(false); + deletedEvent.StrikeId.ShouldBeNull(); + deletedEvent.SearchStatus.ShouldBeNull(); + deletedEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(deletedEvent.Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456"); + data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true); + data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("FailedImport"); + } + + // Notification with FailedImport reason + await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.FailedImport); + } + + [Fact] + public async Task IgnoredDownload_IsSkipped_NoEventsOrNotifications() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var record = CreateQueueRecord(downloadId: "IGNORED_HASH_123"); + + // Add the download ID to the ignored list + var generalConfig = await _fixture.DataContext.GeneralConfigs.FirstAsync(); + generalConfig.IgnoredDownloads.Add("IGNORED_HASH_123"); + await _fixture.DataContext.SaveChangesAsync(); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: No removal requests, no events, no notifications + _fixture.GetCapturedRemoveRequests().ShouldBeEmpty(); + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.ShouldBeEmpty(); + await _fixture.NotificationPublisher.DidNotReceive().NotifyQueueItemDeleted(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PrivateTorrent_RemoveFromClientIsFalse() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + TestDataContextFactory.AddStallRule(_fixture.DataContext); + + var record = CreateQueueRecord(movieId: 50); + + _fixture.SetupArrQueueIterator(record); + _fixture.ArrClient.IsRecordValid(Arg.Any()).Returns(true); + _fixture.ArrClient.HasContentId(Arg.Any()).Returns(true); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any(), Arg.Any>()) + .Returns(new DownloadCheckResult + { + ShouldRemove = true, + Found = true, + DeleteReason = DeleteReason.Stalled, + IsPrivate = true, + DeleteFromClient = false + }); + _fixture.DownloadServiceFactory.GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: RemoveFromClient should be false for private torrents + _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); + + _fixture.ArrClient.DeleteQueueItemAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + await _fixture.ProcessCapturedRemoveRequestsAsync(); + + // The arr client should be told NOT to remove from the download client + await _fixture.ArrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + false, + DeleteReason.Stalled); + + // Full event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + + var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted); + deletedEvent.Message.ShouldBe("Deleting item from queue with reason: Stalled"); + deletedEvent.Severity.ShouldBe(EventSeverity.Important); + deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId); + deletedEvent.ArrInstanceId.ShouldBe(instance.Id); + deletedEvent.IsDryRun.ShouldBe(false); + deletedEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(deletedEvent.Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456"); + data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false); + data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled"); + } + + await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.Stalled); + } + + private static QueueRecord CreateQueueRecord( + long movieId = 1, + string downloadId = "ABC123DEF456", + string title = "Test.Movie.2024.1080p") + { + return new QueueRecord + { + Id = 1, + Title = title, + Protocol = "torrent", + DownloadId = downloadId, + MovieId = movieId, + Status = "warning", + StatusMessages = [] + }; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs new file mode 100644 index 00000000..283de484 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/SeekerIntegrationTests.cs @@ -0,0 +1,221 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence.Models.State; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; +using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[Collection(IntegrationTestCollection.Name)] +public class SeekerIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + + public SeekerIntegrationTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + _fixture.Reset(); + } + + public void Dispose() + { + Striker.RecurringHashes.Clear(); + } + + private SeekerJob CreateSut() + { + var environment = Substitute.For(); + environment.EnvironmentName.Returns("Development"); + + return new SeekerJob( + Substitute.For>(), + _fixture.DataContext, + Substitute.For(), + Substitute.For(), + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.EventPublisher, + _fixture.DryRunInterceptor, + environment, + _fixture.TimeProvider, + _fixture.HubContext); + } + + [Fact] + public async Task ReplacementSearch_TriggersSearch_SavesEvent_SendsNotification_DequesItem() + { + // Arrange + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + // Add a replacement search item to the queue + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = instance.Id, + ItemId = 42, + Title = "Test.Movie.2024.1080p", + }); + await _fixture.DataContext.SaveChangesAsync(); + + // Mock arr client to return command IDs on search + _fixture.ArrClient.SearchItemsAsync(Arg.Any(), Arg.Any>()) + .Returns([100L]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: Full SearchTriggered event property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered); + searchEvent.Message.ShouldBe("Search triggered for Test.Movie.2024.1080p"); + searchEvent.Severity.ShouldBe(EventSeverity.Information); + searchEvent.JobRunId.ShouldBe(_fixture.JobRunId); + searchEvent.ArrInstanceId.ShouldBe(instance.Id); + searchEvent.DownloadClientId.ShouldBeNull(); + searchEvent.IsDryRun.ShouldBe(false); + searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending); + searchEvent.CompletedAt.ShouldBeNull(); + searchEvent.CycleId.ShouldBeNull(); + searchEvent.StrikeId.ShouldBeNull(); + searchEvent.TrackingId.ShouldBeNull(); + searchEvent.Data.ShouldBeNull(); + + // Assert: SearchEventData was created with correct properties + var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync(); + searchData.Count.ShouldBe(1); + searchData[0].AppEventId.ShouldBe(searchEvent.Id); + searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement); + searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement); + searchData[0].ItemTitle.ShouldBe("Test.Movie.2024.1080p"); + searchData[0].GrabbedItems.ShouldBeEmpty(); + + // Assert: Notification was sent + await _fixture.NotificationPublisher.Received(1).NotifySearchTriggered( + "Test.Movie.2024.1080p", + SeekerSearchType.Replacement, + SeekerSearchReason.Replacement); + + // Assert: Item was dequeued from SearchQueue + var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync(); + remainingItems.ShouldBe(0); + + // Assert: Command tracker was saved for monitoring + var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync(); + trackers.Count.ShouldBe(1); + trackers[0].CommandId.ShouldBe(100L); + } + + [Fact] + public async Task SearchDisabled_DoesNothing() + { + // Arrange + var seekerConfig = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + seekerConfig.SearchEnabled = false; + await _fixture.DataContext.SaveChangesAsync(); + + TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + // Add a search queue item that should NOT be processed + var instance = await _fixture.DataContext.ArrInstances.FirstAsync(); + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = instance.Id, + ItemId = 99, + Title = "Should.Not.Search", + }); + await _fixture.DataContext.SaveChangesAsync(); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: No search triggered, no events, no notifications + await _fixture.ArrClient.DidNotReceive().SearchItemsAsync( + Arg.Any(), + Arg.Any>()); + + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.ShouldBeEmpty(); + + var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync(); + searchData.ShouldBeEmpty(); + + await _fixture.NotificationPublisher.DidNotReceive().NotifySearchTriggered( + Arg.Any(), Arg.Any(), Arg.Any()); + + // Item should still be in the queue (not processed) + var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync(); + remainingItems.ShouldBe(1); + } + + [Fact] + public async Task DryRun_TriggersSearch_ButDoesNotDequeue() + { + // Arrange + _fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true); + + var instance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = instance.Id, + ItemId = 55, + Title = "DryRun.Movie.2024", + }); + await _fixture.DataContext.SaveChangesAsync(); + + _fixture.ArrClient.SearchItemsAsync( + Arg.Any(), + Arg.Any>()) + .Returns([200L]); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: Full SearchTriggered event with IsDryRun = true + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var searchEvent = events.First(e => e.EventType == EventType.SearchTriggered); + searchEvent.Message.ShouldBe("Search triggered for DryRun.Movie.2024"); + searchEvent.Severity.ShouldBe(EventSeverity.Information); + searchEvent.JobRunId.ShouldBe(_fixture.JobRunId); + searchEvent.ArrInstanceId.ShouldBe(instance.Id); + searchEvent.IsDryRun.ShouldBe(true); + searchEvent.SearchStatus.ShouldBe(SearchCommandStatus.Pending); + searchEvent.CompletedAt.ShouldBeNull(); + searchEvent.CycleId.ShouldBeNull(); + searchEvent.StrikeId.ShouldBeNull(); + searchEvent.Data.ShouldBeNull(); + + // Assert: SearchEventData created + var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync(); + searchData.Count.ShouldBe(1); + searchData[0].ItemTitle.ShouldBe("DryRun.Movie.2024"); + searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement); + searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement); + searchData[0].GrabbedItems.ShouldBeEmpty(); + + // Assert: Item remains in queue (dry run doesn't dequeue) + var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync(); + remainingItems.ShouldBe(1); + + // Assert: No command tracker saved (dry run) + var trackers = await _fixture.DataContext.SeekerCommandTrackers.ToListAsync(); + trackers.ShouldBeEmpty(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs new file mode 100644 index 00000000..50a11ecf --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/StrikerIntegrationTests.cs @@ -0,0 +1,503 @@ +using System.Text.Json; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; + +[Collection(IntegrationTestCollection.Name)] +public class StrikerIntegrationTests : IDisposable +{ + private readonly IntegrationTestFixture _fixture; + private readonly Guid _arrInstanceId = Guid.NewGuid(); + + public StrikerIntegrationTests(IntegrationTestFixture fixture) + { + _fixture = fixture; + _fixture.Reset(); + SetupContext(); + } + + public void Dispose() + { + Striker.RecurringHashes.Clear(); + } + + private void SetupContext() + { + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, _arrInstanceId); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, new Uri("http://radarr:7878")); + ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Radarr); + } + + [Fact] + public async Task StalledStrike_PublishesEvent_CreatesStrike_SendsNotification() + { + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "STALLED_HASH_123", "Stalled.Movie.2024.1080p", maxStrikes: 3, StrikeType.Stalled); + + // Assert: Should not remove (1 of 3 strikes) + shouldRemove.ShouldBe(false); + + // Assert: Strike record + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].Type.ShouldBe(StrikeType.Stalled); + strikes[0].JobRunId.ShouldBe(_fixture.JobRunId); + strikes[0].IsDryRun.ShouldBe(false); + strikes[0].LastDownloadedBytes.ShouldBeNull(); + + // Assert: DownloadItem record + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].DownloadId.ShouldBe("STALLED_HASH_123"); + downloadItems[0].Title.ShouldBe("Stalled.Movie.2024.1080p"); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); + downloadItems[0].IsRemoved.ShouldBe(false); + downloadItems[0].IsReturning.ShouldBe(false); + + // Assert: FK relationship + strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id); + + // Assert: Full AppEvent property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var strikeEvent = events[0]; + strikeEvent.EventType.ShouldBe(EventType.StalledStrike); + strikeEvent.Message.ShouldBe("Item 'Stalled.Movie.2024.1080p' has been struck 1 times for reason 'Stalled'"); + strikeEvent.Severity.ShouldBe(EventSeverity.Important); + strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId); + strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId); + strikeEvent.DownloadClientId.ShouldBeNull(); + strikeEvent.IsDryRun.ShouldBe(false); + strikeEvent.StrikeId.ShouldBe(strikes[0].Id); + strikeEvent.TrackingId.ShouldBeNull(); + strikeEvent.SearchStatus.ShouldBeNull(); + strikeEvent.CompletedAt.ShouldBeNull(); + strikeEvent.CycleId.ShouldBeNull(); + strikeEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(strikeEvent.Data!)) + { + data.RootElement.GetProperty("hash").GetString().ShouldBe("STALLED_HASH_123"); + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Stalled.Movie.2024.1080p"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1); + data.RootElement.GetProperty("strikeType").GetString().ShouldBe("Stalled"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1); + } + + [Fact] + public async Task DownloadingMetadataStrike_PublishesEvent_CreatesStrike_SendsNotification() + { + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "METADATA_HASH_456", "Metadata.Movie.2024.1080p", maxStrikes: 3, StrikeType.DownloadingMetadata); + + // Assert: Should not remove (1 of 3 strikes) + shouldRemove.ShouldBe(false); + + // Assert: Strike record + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].Type.ShouldBe(StrikeType.DownloadingMetadata); + strikes[0].JobRunId.ShouldBe(_fixture.JobRunId); + strikes[0].IsDryRun.ShouldBe(false); + strikes[0].LastDownloadedBytes.ShouldBeNull(); + + // Assert: DownloadItem record + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].DownloadId.ShouldBe("METADATA_HASH_456"); + downloadItems[0].Title.ShouldBe("Metadata.Movie.2024.1080p"); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); + + // Assert: FK relationship + strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id); + + // Assert: Full AppEvent property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var strikeEvent = events[0]; + strikeEvent.EventType.ShouldBe(EventType.DownloadingMetadataStrike); + strikeEvent.Message.ShouldBe("Item 'Metadata.Movie.2024.1080p' has been struck 1 times for reason 'DownloadingMetadata'"); + strikeEvent.Severity.ShouldBe(EventSeverity.Important); + strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId); + strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId); + strikeEvent.DownloadClientId.ShouldBeNull(); + strikeEvent.IsDryRun.ShouldBe(false); + strikeEvent.StrikeId.ShouldBe(strikes[0].Id); + strikeEvent.TrackingId.ShouldBeNull(); + strikeEvent.SearchStatus.ShouldBeNull(); + strikeEvent.CompletedAt.ShouldBeNull(); + strikeEvent.CycleId.ShouldBeNull(); + strikeEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(strikeEvent.Data!)) + { + data.RootElement.GetProperty("hash").GetString().ShouldBe("METADATA_HASH_456"); + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Metadata.Movie.2024.1080p"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1); + data.RootElement.GetProperty("strikeType").GetString().ShouldBe("DownloadingMetadata"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.DownloadingMetadata, 1); + } + + [Fact] + public async Task FailedImportStrike_PublishesEvent_WithStatusMessages_SendsNotification() + { + // Arrange: FailedImport reads QueueRecord from ContextProvider for StatusMessages + var queueRecord = new QueueRecord + { + Id = 1, + Title = "FailedImport.Movie.2024.1080p", + Protocol = "torrent", + DownloadId = "FAILED_HASH_789", + StatusMessages = + [ + new TrackedDownloadStatusMessage + { + Title = "Import failed", + Messages = ["File not found", "Path does not exist"] + } + ] + }; + ContextProvider.Set(nameof(QueueRecord), queueRecord); + + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "FAILED_HASH_789", "FailedImport.Movie.2024.1080p", maxStrikes: 3, StrikeType.FailedImport); + + // Assert: Should not remove (1 of 3 strikes) + shouldRemove.ShouldBe(false); + + // Assert: Strike record + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].Type.ShouldBe(StrikeType.FailedImport); + strikes[0].JobRunId.ShouldBe(_fixture.JobRunId); + strikes[0].IsDryRun.ShouldBe(false); + strikes[0].LastDownloadedBytes.ShouldBeNull(); + + // Assert: DownloadItem record + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].DownloadId.ShouldBe("FAILED_HASH_789"); + downloadItems[0].Title.ShouldBe("FailedImport.Movie.2024.1080p"); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); + + // Assert: FK relationship + strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id); + + // Assert: Full AppEvent property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var strikeEvent = events[0]; + strikeEvent.EventType.ShouldBe(EventType.FailedImportStrike); + strikeEvent.Message.ShouldBe("Item 'FailedImport.Movie.2024.1080p' has been struck 1 times for reason 'FailedImport'"); + strikeEvent.Severity.ShouldBe(EventSeverity.Important); + strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId); + strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId); + strikeEvent.DownloadClientId.ShouldBeNull(); + strikeEvent.IsDryRun.ShouldBe(false); + strikeEvent.StrikeId.ShouldBe(strikes[0].Id); + strikeEvent.TrackingId.ShouldBeNull(); + strikeEvent.SearchStatus.ShouldBeNull(); + strikeEvent.CompletedAt.ShouldBeNull(); + strikeEvent.CycleId.ShouldBeNull(); + strikeEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(strikeEvent.Data!)) + { + data.RootElement.GetProperty("hash").GetString().ShouldBe("FAILED_HASH_789"); + data.RootElement.GetProperty("itemName").GetString().ShouldBe("FailedImport.Movie.2024.1080p"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1); + data.RootElement.GetProperty("strikeType").GetString().ShouldBe("FailedImport"); + + // FailedImport-specific: includes failedImportReasons from QueueRecord.StatusMessages + var reasons = data.RootElement.GetProperty("failedImportReasons"); + reasons.GetArrayLength().ShouldBe(1); + reasons[0].GetProperty("Title").GetString().ShouldBe("Import failed"); + var messages = reasons[0].GetProperty("Messages"); + messages.GetArrayLength().ShouldBe(2); + messages[0].GetString().ShouldBe("File not found"); + messages[1].GetString().ShouldBe("Path does not exist"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.FailedImport, 1); + } + + [Fact] + public async Task SlowSpeedStrike_PublishesEvent_CreatesStrike_SendsNotification() + { + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "SLOW_SPEED_HASH_111", "SlowSpeed.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowSpeed); + + // Assert: Should not remove (1 of 3 strikes) + shouldRemove.ShouldBe(false); + + // Assert: Strike record + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].Type.ShouldBe(StrikeType.SlowSpeed); + strikes[0].JobRunId.ShouldBe(_fixture.JobRunId); + strikes[0].IsDryRun.ShouldBe(false); + strikes[0].LastDownloadedBytes.ShouldBeNull(); + + // Assert: DownloadItem record + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].DownloadId.ShouldBe("SLOW_SPEED_HASH_111"); + downloadItems[0].Title.ShouldBe("SlowSpeed.Movie.2024.1080p"); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); + + // Assert: FK relationship + strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id); + + // Assert: Full AppEvent property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var strikeEvent = events[0]; + strikeEvent.EventType.ShouldBe(EventType.SlowSpeedStrike); + strikeEvent.Message.ShouldBe("Item 'SlowSpeed.Movie.2024.1080p' has been struck 1 times for reason 'SlowSpeed'"); + strikeEvent.Severity.ShouldBe(EventSeverity.Important); + strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId); + strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId); + strikeEvent.DownloadClientId.ShouldBeNull(); + strikeEvent.IsDryRun.ShouldBe(false); + strikeEvent.StrikeId.ShouldBe(strikes[0].Id); + strikeEvent.TrackingId.ShouldBeNull(); + strikeEvent.SearchStatus.ShouldBeNull(); + strikeEvent.CompletedAt.ShouldBeNull(); + strikeEvent.CycleId.ShouldBeNull(); + strikeEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(strikeEvent.Data!)) + { + data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_SPEED_HASH_111"); + data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowSpeed.Movie.2024.1080p"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1); + data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowSpeed"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowSpeed, 1); + } + + [Fact] + public async Task SlowTimeStrike_PublishesEvent_CreatesStrike_SendsNotification() + { + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "SLOW_TIME_HASH_222", "SlowTime.Movie.2024.1080p", maxStrikes: 3, StrikeType.SlowTime); + + // Assert: Should not remove (1 of 3 strikes) + shouldRemove.ShouldBe(false); + + // Assert: Strike record + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].Type.ShouldBe(StrikeType.SlowTime); + strikes[0].JobRunId.ShouldBe(_fixture.JobRunId); + strikes[0].IsDryRun.ShouldBe(false); + strikes[0].LastDownloadedBytes.ShouldBeNull(); + + // Assert: DownloadItem record + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].DownloadId.ShouldBe("SLOW_TIME_HASH_222"); + downloadItems[0].Title.ShouldBe("SlowTime.Movie.2024.1080p"); + downloadItems[0].IsMarkedForRemoval.ShouldBe(false); + + // Assert: FK relationship + strikes[0].DownloadItemId.ShouldBe(downloadItems[0].Id); + + // Assert: Full AppEvent property verification + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + + var strikeEvent = events[0]; + strikeEvent.EventType.ShouldBe(EventType.SlowTimeStrike); + strikeEvent.Message.ShouldBe("Item 'SlowTime.Movie.2024.1080p' has been struck 1 times for reason 'SlowTime'"); + strikeEvent.Severity.ShouldBe(EventSeverity.Important); + strikeEvent.JobRunId.ShouldBe(_fixture.JobRunId); + strikeEvent.ArrInstanceId.ShouldBe(_arrInstanceId); + strikeEvent.DownloadClientId.ShouldBeNull(); + strikeEvent.IsDryRun.ShouldBe(false); + strikeEvent.StrikeId.ShouldBe(strikes[0].Id); + strikeEvent.TrackingId.ShouldBeNull(); + strikeEvent.SearchStatus.ShouldBeNull(); + strikeEvent.CompletedAt.ShouldBeNull(); + strikeEvent.CycleId.ShouldBeNull(); + strikeEvent.Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(strikeEvent.Data!)) + { + data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_TIME_HASH_222"); + data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowTime.Movie.2024.1080p"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1); + data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowTime"); + } + + // Assert: Notification sent + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowTime, 1); + } + + [Fact] + public async Task StrikeReachingLimit_MarksDownloadItemForRemoval() + { + // Act: 3 strikes with maxStrikes=3 + bool result1 = await _fixture.Striker.StrikeAndCheckLimit( + "LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled); + bool result2 = await _fixture.Striker.StrikeAndCheckLimit( + "LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled); + bool result3 = await _fixture.Striker.StrikeAndCheckLimit( + "LIMIT_HASH_333", "Limit.Movie.2024", maxStrikes: 3, StrikeType.Stalled); + + // Assert: First two return false, third returns true + result1.ShouldBe(false); + result2.ShouldBe(false); + result3.ShouldBe(true); + + // Assert: 3 strikes created for same DownloadItem + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(3); + strikes.ShouldAllBe(s => s.Type == StrikeType.Stalled); + + // Assert: Single DownloadItem marked for removal + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems.Count.ShouldBe(1); + downloadItems[0].IsMarkedForRemoval.ShouldBe(true); + + // Assert: 3 events with incrementing strike counts + var events = await _fixture.EventsContext.Events.OrderBy(e => e.Timestamp).ToListAsync(); + events.Count.ShouldBe(3); + for (int i = 0; i < 3; i++) + { + events[i].EventType.ShouldBe(EventType.StalledStrike); + using var data = JsonDocument.Parse(events[i].Data!); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(i + 1); + } + + // Assert: 3 notifications with incrementing counts + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1); + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 2); + await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 3); + } + + [Fact] + public async Task DryRunStrike_PublishesEventWithDryRunFlag() + { + // Arrange + _fixture.DryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act + bool shouldRemove = await _fixture.Striker.StrikeAndCheckLimit( + "DRYRUN_HASH_444", "DryRun.Movie.2024", maxStrikes: 1, StrikeType.Stalled); + + // Assert: Should remove (at limit) + shouldRemove.ShouldBe(true); + + // Assert: Strike has IsDryRun = true + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].IsDryRun.ShouldBe(true); + + // Assert: AppEvent has IsDryRun = true + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + events[0].IsDryRun.ShouldBe(true); + + // Assert: DownloadItem marked for removal (striker still marks regardless of dry run) + var downloadItems = await _fixture.EventsContext.DownloadItems.ToListAsync(); + downloadItems[0].IsMarkedForRemoval.ShouldBe(true); + } + + [Fact] + public async Task RecurringItem_ExceedsMaxStrikes_PublishesManualEvent() + { + // Act: 3 strikes with maxStrikes=2 (strike 3 exceeds limit) + await _fixture.Striker.StrikeAndCheckLimit( + "RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled); + await _fixture.Striker.StrikeAndCheckLimit( + "RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled); + await _fixture.Striker.StrikeAndCheckLimit( + "RECURRING_HASH_555", "Recurring.Movie.2024", maxStrikes: 2, StrikeType.Stalled); + + // Assert: Hash added to RecurringHashes (lowercased) + Striker.RecurringHashes.ContainsKey("recurring_hash_555").ShouldBe(true); + + // Assert: 3 strike events + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(3); + events.ShouldAllBe(e => e.EventType == EventType.StalledStrike); + + // Assert: ManualEvent published for recurring item + var manualEvents = await _fixture.EventsContext.ManualEvents.ToListAsync(); + manualEvents.Count.ShouldBe(1); + manualEvents[0].Message.ShouldContain("Download keeps coming back after deletion"); + manualEvents[0].Severity.ShouldBe(EventSeverity.Important); + manualEvents[0].JobRunId.ShouldBe(_fixture.JobRunId); + manualEvents[0].Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(manualEvents[0].Data!)) + { + data.RootElement.GetProperty("itemName").GetString().ShouldBe("Recurring.Movie.2024"); + data.RootElement.GetProperty("hash").GetString().ShouldBe("RECURRING_HASH_555"); + data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(3); + } + } + + [Fact] + public async Task StrikeWithLastDownloadedBytes_StoresOnStrikeRecord() + { + // Act + await _fixture.Striker.StrikeAndCheckLimit( + "BYTES_HASH_666", "Bytes.Movie.2024", maxStrikes: 3, StrikeType.SlowSpeed, lastDownloadedBytes: 1024000); + + // Assert: LastDownloadedBytes persisted + var strikes = await _fixture.EventsContext.Strikes.ToListAsync(); + strikes.Count.ShouldBe(1); + strikes[0].LastDownloadedBytes.ShouldBe(1024000); + } + + [Fact] + public async Task FailedImportStrike_EmptyStatusMessages_PublishesEmptyReasons() + { + // Arrange: QueueRecord with empty StatusMessages + var queueRecord = new QueueRecord + { + Id = 1, + Title = "EmptyReasons.Movie.2024", + Protocol = "torrent", + DownloadId = "EMPTY_REASONS_HASH_777", + StatusMessages = [] + }; + ContextProvider.Set(nameof(QueueRecord), queueRecord); + + // Act + await _fixture.Striker.StrikeAndCheckLimit( + "EMPTY_REASONS_HASH_777", "EmptyReasons.Movie.2024", maxStrikes: 3, StrikeType.FailedImport); + + // Assert: failedImportReasons is an empty array + var events = await _fixture.EventsContext.Events.ToListAsync(); + events.Count.ShouldBe(1); + events[0].EventType.ShouldBe(EventType.FailedImportStrike); + events[0].Data.ShouldNotBeNull(); + using (var data = JsonDocument.Parse(events[0].Data!)) + { + var reasons = data.RootElement.GetProperty("failedImportReasons"); + reasons.GetArrayLength().ShouldBe(0); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs index f93c9b54..f26a4cc7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs @@ -9,7 +9,6 @@ using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Moq; @@ -1049,7 +1048,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Radarr && + r.Instance.ArrConfig.Type == InstanceType.Radarr && r.SearchItem.Id == 42 && r.DeleteReason == DeleteReason.Stalled ), @@ -1122,7 +1121,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Lidarr && + r.Instance.ArrConfig.Type == InstanceType.Lidarr && r.SearchItem.Id == 123 && r.DeleteReason == DeleteReason.SlowSpeed ), @@ -1195,7 +1194,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Readarr && + r.Instance.ArrConfig.Type == InstanceType.Readarr && r.SearchItem.Id == 456 && r.DeleteReason == DeleteReason.Stalled ), @@ -1269,7 +1268,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Whisparr && + r.Instance.ArrConfig.Type == InstanceType.Whisparr && r.SearchItem.Id == 100 && // EpisodeId r.SearchItem.SeriesId == 10 && r.SearchItem.SearchType == SeriesSearchType.Episode && @@ -1344,7 +1343,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Whisparr && + r.Instance.ArrConfig.Type == InstanceType.Whisparr && r.SearchItem.Id == 42 && // MovieId r.DeleteReason == DeleteReason.Stalled ), @@ -1431,7 +1430,7 @@ public class QueueCleanerTests : IDisposable _fixture.MessageBus.Verify( x => x.Publish( It.Is>(r => - r.InstanceType == InstanceType.Whisparr && + r.Instance.ArrConfig.Type == InstanceType.Whisparr && r.SearchItem.Id == 3 && // SeasonNumber r.SearchItem.SeriesId == 10 && r.SearchItem.SearchType == SeriesSearchType.Season && diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs index 292c0f19..9bea8d6f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; @@ -102,11 +101,11 @@ public class SeekerCommandMonitorTests : IAsyncDisposable ] }); - var publishTcs = new TaskCompletionSource(); + var publishTcs = new TaskCompletionSource?>(); _eventPublisher.PublishSearchCompleted( - Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.CompletedTask) - .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt(2))); + .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt?>(2))); // Act await _sut.StartAsync(_cts.Token); @@ -115,10 +114,10 @@ public class SeekerCommandMonitorTests : IAsyncDisposable // Assert await _eventPublisher.Received(1).PublishSearchCompleted( - eventId, SearchCommandStatus.Completed, Arg.Any()); + eventId, SearchCommandStatus.Completed, Arg.Any?>()); resultData.ShouldNotBeNull(); - GetGrabbedItems(resultData).GetArrayLength().ShouldBe(1); + resultData!.Count.ShouldBe(1); } [Fact] @@ -158,11 +157,11 @@ public class SeekerCommandMonitorTests : IAsyncDisposable ] }); - var publishTcs = new TaskCompletionSource(); + var publishTcs = new TaskCompletionSource?>(); _eventPublisher.PublishSearchCompleted( - Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.CompletedTask) - .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt(2))); + .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt?>(2))); // Act await _sut.StartAsync(_cts.Token); @@ -171,9 +170,8 @@ public class SeekerCommandMonitorTests : IAsyncDisposable // Assert resultData.ShouldNotBeNull(); - var grabbedItems = GetGrabbedItems(resultData); - grabbedItems.GetArrayLength().ShouldBe(1); - grabbedItems[0].GetProperty("Title").GetString().ShouldBe("Valid Download"); + resultData!.Count.ShouldBe(1); + resultData[0].ShouldBe("Valid Download"); } [Fact] @@ -212,11 +210,11 @@ public class SeekerCommandMonitorTests : IAsyncDisposable ] }); - var publishTcs = new TaskCompletionSource(); + var publishTcs = new TaskCompletionSource?>(); _eventPublisher.PublishSearchCompleted( - Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any?>()) .Returns(Task.CompletedTask) - .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt(2))); + .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt?>(2))); // Act await _sut.StartAsync(_cts.Token); @@ -225,13 +223,7 @@ public class SeekerCommandMonitorTests : IAsyncDisposable // Assert resultData.ShouldNotBeNull(); - GetGrabbedItems(resultData).GetArrayLength().ShouldBe(2); + resultData!.Count.ShouldBe(2); } - private static JsonElement GetGrabbedItems(object resultData) - { - var json = JsonSerializer.Serialize(resultData); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.GetProperty("GrabbedItems").Clone(); - } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 27cbf655..e6eb5eb1 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -9,7 +9,6 @@ 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; @@ -68,9 +67,8 @@ public class SeekerTests : IDisposable _fixture.EventPublisher .Setup(x => x.PublishSearchTriggered( It.IsAny(), - It.IsAny(), - It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(Guid.NewGuid()); } @@ -119,9 +117,8 @@ public class SeekerTests : IDisposable _fixture.EventPublisher.Verify( x => x.PublishSearchTriggered( It.IsAny(), - It.IsAny(), - It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); } @@ -147,9 +144,8 @@ public class SeekerTests : IDisposable _fixture.EventPublisher.Verify( x => x.PublishSearchTriggered( It.IsAny(), - It.IsAny(), - It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); } @@ -191,10 +187,9 @@ public class SeekerTests : IDisposable _fixture.EventPublisher.Verify( x => x.PublishSearchTriggered( - radarrInstance.Name, - 1, - It.Is>(items => items.Contains("Test Movie")), + "Test Movie", SeekerSearchType.Replacement, + SeekerSearchReason.Replacement, It.IsAny()), Times.Once); @@ -294,9 +289,8 @@ public class SeekerTests : IDisposable _fixture.EventPublisher.Verify( x => x.PublishSearchTriggered( It.IsAny(), - It.IsAny(), - It.IsAny>(), SeekerSearchType.Proactive, + It.IsAny(), It.IsAny()), Times.Never); } @@ -961,9 +955,8 @@ public class SeekerTests : IDisposable _fixture.EventPublisher.Verify( x => x.PublishSearchTriggered( It.IsAny(), - It.IsAny(), - It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs deleted file mode 100644 index 54fe02ff..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs +++ /dev/null @@ -1,80 +0,0 @@ -// using Common.Configuration; -// using Common.Enums; -// using Infrastructure.Configuration; -// using Infrastructure.Http; -// using Infrastructure.Services; -// using Microsoft.Extensions.Logging; -// using NSubstitute; -// -// namespace Infrastructure.Tests.Http; -// -// public class DynamicHttpClientProviderFixture : IDisposable -// { -// public ILogger Logger { get; } -// -// public DynamicHttpClientProviderFixture() -// { -// Logger = Substitute.For>(); -// } -// -// public DynamicHttpClientProvider CreateSut() -// { -// var httpClientFactory = Substitute.For(); -// var configManager = Substitute.For(); -// var certificateValidationService = Substitute.For(); -// -// return new DynamicHttpClientProvider( -// Logger, -// httpClientFactory, -// configManager, -// certificateValidationService); -// } -// -// public DownloadClientConfig CreateQBitClientConfig() -// { -// return new DownloadClientConfig -// { -// Id = Guid.NewGuid(), -// Name = "QBit Test", -// Type = DownloadClientType.QBittorrent, -// Enabled = true, -// Host = new("http://localhost:8080"), -// Username = "admin", -// Password = "adminadmin" -// }; -// } -// -// public DownloadClientConfig CreateTransmissionClientConfig() -// { -// return new DownloadClientConfig -// { -// Id = Guid.NewGuid(), -// Name = "Transmission Test", -// Type = DownloadClientType.Transmission, -// Enabled = true, -// Host = new("http://localhost:9091"), -// Username = "admin", -// Password = "adminadmin", -// UrlBase = "transmission" -// }; -// } -// -// public DownloadClientConfig CreateDelugeClientConfig() -// { -// return new DownloadClientConfig -// { -// Id = Guid.NewGuid(), -// Name = "Deluge Test", -// Type = DownloadClientType.Deluge, -// Enabled = true, -// Host = new("http://localhost:8112"), -// Username = "admin", -// Password = "deluge" -// }; -// } -// -// public void Dispose() -// { -// // Cleanup if needed -// } -// } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs deleted file mode 100644 index 4a5f5b70..00000000 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -// using System.Net; -// using Common.Enums; -// using Infrastructure.Http; -// using Shouldly; -// -// namespace Infrastructure.Tests.Http; -// -// public class DynamicHttpClientProviderTests : IClassFixture -// { -// private readonly DynamicHttpClientProviderFixture _fixture; -// -// public DynamicHttpClientProviderTests(DynamicHttpClientProviderFixture fixture) -// { -// _fixture = fixture; -// } -// -// [Fact] -// public void CreateClient_WithQBitConfig_ShouldReturnConfiguredClient() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateQBitClientConfig(); -// -// // Act -// var httpClient = sut.CreateClient(config); -// -// // Assert -// httpClient.ShouldNotBeNull(); -// httpClient.BaseAddress.ShouldBe(config.Url); -// VerifyDefaultHttpClientProperties(httpClient); -// } -// -// [Fact] -// public void CreateClient_WithTransmissionConfig_ShouldReturnConfiguredClient() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateTransmissionClientConfig(); -// -// // Act -// var httpClient = sut.CreateClient(config); -// -// // Assert -// httpClient.ShouldNotBeNull(); -// httpClient.BaseAddress.ShouldBe(config.Url); -// VerifyDefaultHttpClientProperties(httpClient); -// } -// -// [Fact] -// public void CreateClient_WithDelugeConfig_ShouldReturnConfiguredClient() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateDelugeClientConfig(); -// -// // Act -// var httpClient = sut.CreateClient(config); -// -// // Assert -// httpClient.ShouldNotBeNull(); -// httpClient.BaseAddress.ShouldBe(config.Url); -// -// // Deluge client should have additional properties configured -// VerifyDelugeHttpClientProperties(httpClient); -// } -// -// [Fact] -// public void CreateClient_WithSameConfig_ShouldReturnUniqueInstances() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateQBitClientConfig(); -// -// // Act -// var firstClient = sut.CreateClient(config); -// var secondClient = sut.CreateClient(config); -// -// // Assert -// firstClient.ShouldNotBeNull(); -// secondClient.ShouldNotBeNull(); -// firstClient.ShouldNotBeSameAs(secondClient); // Should be different instances -// } -// -// [Fact] -// public void CreateClient_WithCustomCertificateValidation_ShouldConfigureHandler() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateQBitClientConfig(); -// -// // Act -// var httpClient = sut.CreateClient(config); -// -// // Assert -// httpClient.ShouldNotBeNull(); -// -// // Since we can't directly access the handler settings after creation, -// // we verify the behavior is working by checking if the client can be created properly -// httpClient.BaseAddress.ShouldBe(config.Url); -// } -// -// [Fact] -// public void CreateClient_WithTimeout_ShouldConfigureTimeout() -// { -// // Arrange -// var sut = _fixture.CreateSut(); -// var config = _fixture.CreateQBitClientConfig(); -// TimeSpan expectedTimeout = TimeSpan.FromSeconds(30); -// -// // Act -// var httpClient = sut.CreateClient(config); -// -// // Assert -// httpClient.Timeout.ShouldBe(expectedTimeout); -// } -// -// private void VerifyDefaultHttpClientProperties(HttpClient httpClient) -// { -// // Check common properties that should be set for all clients -// httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(30)); -// httpClient.DefaultRequestHeaders.ShouldNotBeNull(); -// } -// -// private void VerifyDelugeHttpClientProperties(HttpClient httpClient) -// { -// // Verify Deluge-specific HTTP client configurations -// VerifyDefaultHttpClientProperties(httpClient); -// -// // Using reflection to access the handler is tricky and potentially brittle -// // Instead, we focus on verifying the client itself is properly configured -// httpClient.BaseAddress.ShouldNotBeNull(); -// } -// } diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index eade5573..c0641bfa 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -11,6 +11,8 @@ using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Events; using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Events; @@ -57,6 +59,8 @@ public class EventPublisher : IEventPublisher TrackingId = trackingId, StrikeId = strikeId, JobRunId = ContextProvider.TryGetJobRunId(), + ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?, + DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?, 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, @@ -64,7 +68,9 @@ public class EventPublisher : IEventPublisher }; eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled(); - await SaveEventToDatabase(eventEntity); + + _context.Events.Add(eventEntity); + await _context.SaveChangesAsync(); await NotifyClientsAsync(eventEntity); @@ -89,7 +95,9 @@ public class EventPublisher : IEventPublisher }; eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled(); - await SaveManualEventToDatabase(eventEntity); + + _context.ManualEvents.Add(eventEntity); + await _context.SaveChangesAsync(); await NotifyClientsAsync(eventEntity); @@ -231,21 +239,17 @@ 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) + public async Task PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, 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}", + Message = $"Search triggered for {itemTitle}", 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(), + ArrInstanceId = ContextProvider.Get(ContextProvider.Keys.ArrInstanceId) as Guid?, + DownloadClientId = ContextProvider.Get(ContextProvider.Keys.DownloadClientId) as Guid?, 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, @@ -254,19 +258,44 @@ public class EventPublisher : IEventPublisher }; eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled(); - await SaveEventToDatabase(eventEntity); + + await using IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync(); + + try + { + _context.Events.Add(eventEntity); + _context.SearchEventData.Add(new SearchEventData + { + AppEventId = eventEntity.Id, + ItemTitle = itemTitle, + SearchType = searchType, + SearchReason = searchReason, + }); + + await _context.SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + await NotifyClientsAsync(eventEntity); - await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList); + await _notificationPublisher.NotifySearchTriggered(itemTitle, searchType, searchReason); return eventEntity.Id; } /// - /// Updates an existing search event with completion status and optional result data + /// Updates an existing search event with completion status and optional grabbed item titles /// - public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null) + public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List? grabbedItems = null) { - var existingEvent = await _context.Events.FindAsync(eventId); + var existingEvent = await _context.Events + .Include(e => e.SearchEventData) + .FirstOrDefaultAsync(e => e.Id == eventId); + if (existingEvent is null) { _logger.LogWarning("Could not find search event {EventId} to update completion status", eventId); @@ -276,31 +305,9 @@ public class EventPublisher : IEventPublisher existingEvent.SearchStatus = status; existingEvent.CompletedAt = DateTime.UtcNow; - if (resultData is not null) + if (grabbedItems is { Count: > 0 } && existingEvent.SearchEventData 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() } - }); - } + existingEvent.SearchEventData.GrabbedItems = grabbedItems; } await _context.SaveChangesAsync(); @@ -319,18 +326,6 @@ public class EventPublisher : IEventPublisher ); } - private async Task SaveEventToDatabase(AppEvent eventEntity) - { - _context.Events.Add(eventEntity); - await _context.SaveChangesAsync(); - } - - private async Task SaveManualEventToDatabase(ManualEvent eventEntity) - { - _context.ManualEvents.Add(eventEntity); - await _context.SaveChangesAsync(); - } - private async Task NotifyClientsAsync(AppEvent appEventEntity) { try diff --git a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs index a691aae8..3080cde0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs @@ -20,7 +20,7 @@ public interface IEventPublisher Task PublishSearchNotTriggered(string hash, string itemName); - Task PublishSearchTriggered(string instanceName, int itemCount, IEnumerable items, SeekerSearchType searchType, Guid? cycleId = null); + Task PublishSearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason, Guid? cycleId = null); - Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null); + Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List? grabbedItems = 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 7232bba0..c3c2e6ee 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -8,7 +8,6 @@ using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Shared.Helpers; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index 8b436fe6..2acb78ca 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -2,7 +2,6 @@ using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index 7678782d..11073d2e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index ee151468..6e2dcfe7 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index 705f5a15..1004821d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -6,7 +6,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index ae7ee02b..518add03 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -7,7 +7,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Series = Cleanuparr.Domain.Entities.Sonarr.Series; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs index ec2ed4db..a20eb3be 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs @@ -8,7 +8,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs index b4fda4db..b0d1b238 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs @@ -7,7 +7,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; using Microsoft.Extensions.Logging; using Newtonsoft.Json; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs index 6a6aaf98..e82b537c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Context/ContextProvider.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using Cleanuparr.Persistence.Models.Configuration; namespace Cleanuparr.Infrastructure.Features.Context; @@ -43,14 +44,24 @@ public static class ContextProvider public static void SetJobRunId(Guid id) => Set(JobRunIdKey, id); + public static void SetDownloadClient(DownloadClientConfig config) + { + Set(Keys.DownloadClientUrl, config.ExternalOrInternalUrl); + Set(Keys.DownloadClientId, config.Id); + Set(Keys.DownloadClientType, config.TypeName); + Set(Keys.DownloadClientName, config.Name); + } + public static class Keys { public const string Version = "version"; public const string ItemName = "itemName"; public const string Hash = "hash"; public const string DownloadClientUrl = "downloadClientUrl"; + public const string DownloadClientId = "downloadClientId"; public const string DownloadClientType = "downloadClientType"; public const string DownloadClientName = "downloadClientName"; + public const string ArrInstanceId = "arrInstanceId"; public const string ArrInstanceUrl = "arrInstanceUrl"; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index 2339febe..7776f414 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -27,7 +27,8 @@ public partial class DelugeService result.IsPrivate = download.Private; result.Found = true; - + SetDownloadClientContext(); + if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index 3b0f3f22..4319410c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -75,9 +75,7 @@ public partial class DelugeService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); DelugeContents? contents; try diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs index f89a3b77..09a20ce9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs @@ -29,6 +29,7 @@ public partial class DelugeService result.IsPrivate = download.Private; result.Found = true; + SetDownloadClientContext(); // Create ITorrentItem wrapper for consistent interface usage DelugeItemWrapper torrent = new(download); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 3cd86420..de53296b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -58,6 +58,11 @@ public abstract class DownloadService : IDownloadService public DownloadClientConfig ClientConfig => _downloadClientConfig; + protected void SetDownloadClientContext() + { + ContextProvider.SetDownloadClient(_downloadClientConfig); + } + public abstract void Dispose(); public abstract Task LoginAsync(); @@ -107,9 +112,7 @@ public abstract class DownloadService : IDownloadService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); TimeSpan seedingTime = TimeSpan.FromSeconds(torrent.SeedingTimeSeconds); SeedingCheckResult result = ShouldCleanDownload(torrent.Ratio, seedingTime, category); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index b7177adc..d10a67eb 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -47,6 +47,7 @@ public partial class QBitService result.IsPrivate = isPrivate; result.Found = true; + SetDownloadClientContext(); var malwareBlockerConfig = ContextProvider.Get(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index 4d21a3bb..b15565a1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -103,9 +103,7 @@ public partial class QBitService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); bool hasHardlinks = false; bool hasErrors = false; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs index b260a393..4da449f9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs @@ -38,6 +38,7 @@ public partial class QBitService && boolValue; result.Found = true; + SetDownloadClientContext(); // Create ITorrentItem wrapper for consistent interface usage QBitItemWrapper torrent = new(download, trackers, result.IsPrivate); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 9d465f2f..879ff1f2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -27,6 +27,7 @@ public partial class RTorrentService result.IsPrivate = download.IsPrivate == 1; result.Found = true; + SetDownloadClientContext(); // Get trackers for ignore check var trackers = await _client.GetTrackersAsync(hash); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 44ef0825..06584973 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -72,9 +72,7 @@ public partial class RTorrentService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); List files; try diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs index 25c6ba83..c00eebe8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs @@ -25,6 +25,7 @@ public partial class RTorrentService result.IsPrivate = download.IsPrivate == 1; result.Found = true; + SetDownloadClientContext(); // Get trackers for ignore check var trackers = await _client.GetTrackersAsync(hash); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index 0243dbf8..77e7baa9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -38,7 +38,8 @@ public partial class TransmissionService bool isPrivate = download.IsPrivate ?? false; result.IsPrivate = isPrivate; result.Found = true; - + SetDownloadClientContext(); + var malwareBlockerConfig = ContextProvider.Get(); if (malwareBlockerConfig.IgnorePrivate && isPrivate) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index a1de2ab7..ba968ccc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -66,9 +66,7 @@ public partial class TransmissionService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); if (torrent.Info.Files is null || torrent.Info.FileStats is null) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs index 2454f51f..919ad783 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs @@ -27,6 +27,7 @@ public partial class TransmissionService bool isPrivate = download.IsPrivate ?? false; result.IsPrivate = isPrivate; result.Found = true; + SetDownloadClientContext(); // Create ITorrentItem wrapper for consistent interface usage TransmissionItemWrapper torrent = new(download); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index 996009cc..4d77ce19 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -29,8 +29,9 @@ public partial class UTorrentService var properties = await _client.GetTorrentPropertiesAsync(hash); result.IsPrivate = properties.IsPrivate; result.Found = true; - - if (ignoredDownloads.Count > 0 && + SetDownloadClientContext(); + + if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads)))) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index cfe1394f..603a0c50 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -64,9 +64,7 @@ public partial class UTorrentService ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl); - ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName); - ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name); + SetDownloadClientContext(); List? files = await _client.GetTorrentFilesAsync(torrent.Hash); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs index 2a35fa4a..12b9b1bf 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs @@ -28,6 +28,7 @@ public partial class UTorrentService var properties = await _client.GetTorrentPropertiesAsync(hash); result.IsPrivate = properties.IsPrivate; result.Found = true; + SetDownloadClientContext(); // Create ITorrentItem wrapper for consistent interface usage UTorrentItemWrapper torrent = new(download, properties); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs index 1eb19b51..cfe0d778 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Consumers/DownloadRemoverConsumer.cs @@ -1,6 +1,6 @@ -using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; -using Data.Models.Arr; using MassTransit; using Microsoft.Extensions.Logging; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Interfaces/IQueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Interfaces/IQueueItemRemover.cs index bee5624c..44cfbe57 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Interfaces/IQueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Interfaces/IQueueItemRemover.cs @@ -1,5 +1,5 @@ -using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; -using Data.Models.Arr; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs index 7a9de640..15b7a5af 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs @@ -1,21 +1,20 @@ -using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; -using Data.Models.Arr; namespace Cleanuparr.Infrastructure.Features.DownloadRemover.Models; public sealed record QueueItemRemoveRequest 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 bool RemoveFromClient { get; init; } public required DeleteReason DeleteReason { get; init; } @@ -23,4 +22,6 @@ public sealed record QueueItemRemoveRequest public required Guid JobRunId { get; init; } public bool SkipSearch { get; init; } -} \ No newline at end of file + + public DownloadClientConfig? DownloadClient { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index 18e491dd..be21cc61 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -12,7 +12,6 @@ using Cleanuparr.Infrastructure.Helpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Seeker; using Cleanuparr.Persistence.Models.State; -using Data.Models.Arr; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -50,7 +49,8 @@ public sealed class QueueItemRemover : IQueueItemRemover { try { - var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version); + var instanceType = request.Instance.ArrConfig.Type; + var arrClient = _arrClientFactory.GetClient(instanceType, request.Instance.Version); await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason); // Mark the download item as removed in the database @@ -61,16 +61,22 @@ public sealed class QueueItemRemover : IQueueItemRemover setter.SetProperty(x => x.IsRemoved, true); setter.SetProperty(x => x.IsMarkedForRemoval, false); }); - + // Set context for EventPublisher ContextProvider.SetJobRunId(request.JobRunId); ContextProvider.Set(ContextProvider.Keys.ItemName, request.Record.Title); ContextProvider.Set(ContextProvider.Keys.Hash, request.Record.DownloadId); ContextProvider.Set(nameof(QueueRecord), request.Record); - ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalUrl ?? request.Instance.Url); - ContextProvider.Set(nameof(InstanceType), request.InstanceType); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, request.Instance.ExternalOrInternalUrl); + ContextProvider.Set(nameof(InstanceType), instanceType); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, request.Instance.Id); ContextProvider.Set(ContextProvider.Keys.Version, request.Instance.Version); + if (request.DownloadClient is not null) + { + ContextProvider.SetDownloadClient(request.DownloadClient); + } + await _eventPublisher.PublishQueueItemDeleted(request.RemoveFromClient, request.DeleteReason); string hash = request.Record.DownloadId.ToLowerInvariant(); @@ -116,7 +122,7 @@ public sealed class QueueItemRemover : IQueueItemRemover throw; } - throw new Exception($"Item might have already been deleted by your {request.InstanceType} instance", exception); + throw new Exception($"Item might have already been deleted by your {request.Instance.ArrConfig.Type} instance", exception); } finally { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index b15a8897..e8b385fd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -13,7 +13,6 @@ 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 Data.Models.Arr; using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -126,13 +125,13 @@ public abstract class GenericHandler : IHandler protected async Task PublishQueueItemRemoveRequest( string downloadRemovalKey, - InstanceType instanceType, ArrInstance instance, QueueRecord record, bool isPack, bool removeFromClient, DeleteReason deleteReason, - bool skipSearch = false + bool skipSearch = false, + DownloadClientConfig? downloadClient = null ) { if (_cache.TryGetValue(downloadRemovalKey, out bool _)) @@ -141,18 +140,20 @@ public abstract class GenericHandler : IHandler return; } + var instanceType = instance.ArrConfig.Type; + if (instanceType is InstanceType.Sonarr || (instanceType is InstanceType.Whisparr && instance.Version is 2)) { QueueItemRemoveRequest removeRequest = new() { - InstanceType = instanceType, Instance = instance, Record = record, SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack), RemoveFromClient = removeFromClient, DeleteReason = deleteReason, JobRunId = ContextProvider.GetJobRunId(), - SkipSearch = skipSearch + SkipSearch = skipSearch, + DownloadClient = downloadClient, }; await _messageBus.Publish(removeRequest); @@ -161,19 +162,25 @@ public abstract class GenericHandler : IHandler { QueueItemRemoveRequest removeRequest = new() { - InstanceType = instanceType, Instance = instance, Record = record, SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack), RemoveFromClient = removeFromClient, DeleteReason = deleteReason, JobRunId = ContextProvider.GetJobRunId(), - SkipSearch = skipSearch + SkipSearch = skipSearch, + DownloadClient = downloadClient, }; await _messageBus.Publish(removeRequest); } + // Set context for event + if (downloadClient is not null) + { + ContextProvider.SetDownloadClient(downloadClient); + } + _logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url); await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important, data: new { itemName = record.Title, hash = record.DownloadId }); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index 8009a5bc..995ae9cd 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -105,8 +105,9 @@ public sealed class MalwareBlocker : GenericHandler IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version); // push to context - ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalOrInternalUrl); ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, instance.Id); ContextProvider.Set(ContextProvider.Keys.Version, instance.Version); IReadOnlyList downloadServices = await GetInitializedDownloadServicesAsync(); @@ -162,15 +163,16 @@ public sealed class MalwareBlocker : GenericHandler BlockFilesResult result = new(); bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase); + DownloadClientConfig? foundInClient = null; if (isTorrent) { var torrentClients = downloadServices .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) .ToList(); - + _logger.LogDebug("searching unwanted files for {title}", record.Title); - + if (torrentClients.Count > 0) { // Check each download client for the download item @@ -181,19 +183,20 @@ public sealed class MalwareBlocker : GenericHandler // stalled download check result = await downloadService .BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads); - + if (result.Found) { + foundInClient = downloadService.ClientConfig; break; } } catch (Exception ex) { - _logger.LogError(ex, "Error checking download {dName} with download client {cName}", + _logger.LogError(ex, "Error checking download {dName} with download client {cName}", record.Title, downloadService.ClientConfig.Name); } } - + if (!result.Found) { _logger.LogWarning("Download not found in any torrent client | {title}", record.Title); @@ -209,23 +212,23 @@ public sealed class MalwareBlocker : GenericHandler { continue; } - + bool removeFromClient = true; - + if (result.IsPrivate && !config.DeletePrivate) { removeFromClient = false; } - + await PublishQueueItemRemoveRequest( downloadRemovalKey, - instance.ArrConfig.Type, instance, record, group.Count() > 1, removeFromClient, result.DeleteReason, - skipSearch: !hasContentId + skipSearch: !hasContentId, + downloadClient: foundInClient ); } }); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs index 252f9f9e..41ca3cc0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs @@ -91,8 +91,9 @@ public sealed class QueueCleaner : GenericHandler IArrClient arrClient = _arrClientFactory.GetClient(instance.ArrConfig.Type, instance.Version); // push to context - ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalUrl ?? instance.Url); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, instance.ExternalOrInternalUrl); ContextProvider.Set(nameof(InstanceType), instance.ArrConfig.Type); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, instance.Id); ContextProvider.Set(ContextProvider.Keys.Version, instance.Version); IReadOnlyList downloadServices = await GetInitializedDownloadServicesAsync(); @@ -150,13 +151,14 @@ public sealed class QueueCleaner : GenericHandler DownloadCheckResult downloadCheckResult = new(); bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase); + DownloadClientConfig? foundInClient = null; if (isTorrent) { var torrentClients = downloadServices .Where(x => x.ClientConfig.Type is DownloadClientType.Torrent) .ToList(); - + if (torrentClients.Count > 0) { // Check each download client for the download item @@ -167,19 +169,20 @@ public sealed class QueueCleaner : GenericHandler // Get torrent info from download service for rule evaluation downloadCheckResult = await downloadService .ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads); - + if (downloadCheckResult.Found) { + foundInClient = downloadService.ClientConfig; break; } } catch (Exception ex) { - _logger.LogError(ex, "Error checking download {dName} with download client {cName}", + _logger.LogError(ex, "Error checking download {dName} with download client {cName}", record.Title, downloadService.ClientConfig.Name); } } - + if (!downloadCheckResult.Found) { _logger.LogWarning("Download not found in any torrent client | {title}", record.Title); @@ -193,13 +196,13 @@ public sealed class QueueCleaner : GenericHandler await PublishQueueItemRemoveRequest( downloadRemovalKey, - instance.ArrConfig.Type, instance, record, group.Count() > 1, removeFromClient, downloadCheckResult.DeleteReason, - skipSearch: !hasContentId + skipSearch: !hasContentId, + downloadClient: foundInClient ); continue; @@ -219,16 +222,16 @@ public sealed class QueueCleaner : GenericHandler if (shouldRemoveFromArr) { bool removeFromClient = !downloadCheckResult.IsPrivate || queueCleanerConfig.FailedImport.DeletePrivate; - + await PublishQueueItemRemoveRequest( downloadRemovalKey, - instance.ArrConfig.Type, instance, record, group.Count() > 1, removeFromClient, DeleteReason.FailedImport, - skipSearch: !hasContentId + skipSearch: !hasContentId, + downloadClient: foundInClient ); continue; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 3b4af6be..88f7793f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -11,7 +11,6 @@ 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; @@ -150,7 +149,8 @@ public sealed class Seeker : IHandler } ContextProvider.Set(nameof(InstanceType), item.ArrInstance.ArrConfig.Type); - ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, arrInstance.Id); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalOrInternalUrl); try { @@ -159,7 +159,7 @@ public sealed class Seeker : IHandler List commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems); - Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, 1, [item.Title], SeekerSearchType.Replacement); + Guid eventId = await _eventPublisher.PublishSearchTriggered(item.Title, SeekerSearchType.Replacement, SeekerSearchReason.Replacement); if (!isDryRun) { @@ -259,7 +259,8 @@ public sealed class Seeker : IHandler // Set context for event publishing ContextProvider.Set(nameof(InstanceType), instanceType); - ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceId, arrInstance.Id); + ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalOrInternalUrl); // Fetch queue once for both active download limit check and queue cross-referencing IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version); @@ -346,11 +347,7 @@ public sealed class Seeker : IHandler .Where(r => ActiveQueueStates.Contains(r.TrackedDownloadState)) .ToList(); - HashSet searchItems; - List selectedNames; - List allLibraryIds; - List historyIds; - int seasonNumber = 0; + SeekerProcessResult result; if (instanceType == InstanceType.Radarr) { @@ -359,10 +356,7 @@ public sealed class Seeker : IHandler .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; + result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds); } else { @@ -371,49 +365,60 @@ public sealed class Seeker : IHandler .Select(r => (r.SeriesId, r.SeasonNumber)) .ToHashSet(); - (searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) = - await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons); + result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons); } - if (searchItems.Count == 0) + if (result.Candidates.Count == 0) { _logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name); if (!isDryRun) { - await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds); + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds); } return false; } - // Trigger search (arr client guards the HTTP request via dry run interceptor) + // Search each item individually so each gets its own event and command tracker 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); + foreach (SeekerSearchCandidate candidate in result.Candidates) + { + SearchItem searchItem = instanceType == InstanceType.Radarr + ? new SearchItem { Id = candidate.ItemId } + : new SeriesSearchItem + { + Id = candidate.SeasonNumber, + SeriesId = candidate.ItemId, + SearchType = SeriesSearchType.Season + }; - _logger.LogInformation("Searched {Count} items on {InstanceName}: {Items}", - searchItems.Count, arrInstance.Name, string.Join(", ", selectedNames)); + List commandIds = await arrClient.SearchItemsAsync(arrInstance, [searchItem]); - // Update search history (always, so stats are accurate during dry run) - await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, historyIds, selectedNames, seasonNumber, isDryRun); + Guid eventId = await _eventPublisher.PublishSearchTriggered( + candidate.Name, SeekerSearchType.Proactive, candidate.Reason, instanceConfig.CurrentCycleId); + + _logger.LogInformation("Search triggered for {Item} ({Reason}) | {InstanceUrl}", candidate.Name, candidate.Reason, arrInstance.Url); + + await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, + [candidate.ItemId], [candidate.Name], candidate.SeasonNumber, isDryRun); + + if (!isDryRun) + { + await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType, + candidate.ItemId, candidate.Name, candidate.SeasonNumber); + } + } 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 CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds); await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId); } return true; } - private async Task<(List SelectedIds, List SelectedNames, List AllLibraryIds)> ProcessRadarrAsync( + private async Task ProcessRadarrAsync( SeekerConfig config, ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, @@ -460,7 +465,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return ([], [], allLibraryIds); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } // Exclude movies already in the download queue @@ -480,7 +485,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return ([], [], allLibraryIds); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } } @@ -495,7 +500,7 @@ public sealed class Seeker : IHandler _logger.LogDebug( "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return ([], [], allLibraryIds); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } _logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle", @@ -520,27 +525,32 @@ public sealed class Seeker : IHandler 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(); - + List searchCandidates = []; foreach (long movieId in selectedIds) { SearchableMovie movie = candidates.First(m => m.Id == movieId); - string reason = !movie.HasFile - ? "missing file" + SeekerSearchReason reason = !movie.HasFile + ? SeekerSearchReason.Missing : config.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false) - ? "does not meet quality cutoff" - : "custom format score below cutoff"; + ? SeekerSearchReason.QualityCutoffNotMet + : SeekerSearchReason.CustomFormatScoreBelowCutoff; + + searchCandidates.Add(new SeekerSearchCandidate + { + ItemId = movieId, + Name = movie.Title, + SeasonNumber = 0, + Reason = reason, + }); + _logger.LogDebug("Selected '{Title}' for search on {InstanceName}: {Reason}", movie.Title, arrInstance.Name, reason); } - return (selectedIds, selectedNames, allLibraryIds); + return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds }; } - private async Task<(HashSet SearchItems, List SelectedNames, List AllLibraryIds, List HistoryIds, int SeasonNumber)> ProcessSonarrAsync( + private async Task ProcessSonarrAsync( SeekerConfig config, ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, @@ -577,7 +587,7 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return ([], [], allLibraryIds, [], 0); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } // Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion @@ -594,7 +604,7 @@ public sealed class Seeker : IHandler foreach (long seriesId in candidateIds) { string seriesTitle = string.Empty; - + try { List seriesHistory = currentCycleHistory @@ -603,15 +613,27 @@ public sealed class Seeker : IHandler seriesTitle = candidates.First(s => s.Id == seriesId).Title; - (SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode) = + (SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode, SeekerSearchReason searchReason) = 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); + return new SeekerProcessResult + { + Candidates = + [ + new SeekerSearchCandidate + { + ItemId = seriesId, + Name = displayName, + SeasonNumber = (int)searchItem.Id, + Reason = searchReason, + } + ], + AllLibraryIds = allLibraryIds, + }; } _logger.LogDebug("Skipping '{SeriesTitle}' — no qualifying seasons found", seriesTitle); @@ -632,7 +654,7 @@ public sealed class Seeker : IHandler _logger.LogDebug( "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return ([], [], allLibraryIds, [], 0); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } _logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle", @@ -649,14 +671,14 @@ public sealed class Seeker : IHandler new Dictionary(), [], isDryRun, isRetry: true, queuedSeasons: queuedSeasons); } - return ([], [], allLibraryIds, [], 0); + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds }; } /// /// 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( + private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode, SeekerSearchReason SearchReason)> BuildSonarrSearchItemAsync( SeekerConfig config, ArrInstance arrInstance, long seriesId, @@ -705,7 +727,7 @@ public sealed class Seeker : IHandler if (qualifying.Count == 0) { - return (null, null); + return (null, null, default); } // Select least-recently-searched season using history @@ -742,7 +764,7 @@ public sealed class Seeker : IHandler if (unsearched.Count == 0) { // All unsearched seasons are either searched or in the queue - return (null, null); + return (null, null, default); } // Pick from unsearched seasons with some randomization @@ -773,6 +795,13 @@ public sealed class Seeker : IHandler reasons.Add($"{cfCount} below CF score cutoff"); } + // Determine the primary search reason + SeekerSearchReason searchReason = missingCount > 0 + ? SeekerSearchReason.Missing + : cutoffCount > 0 + ? SeekerSearchReason.QualityCutoffNotMet + : SeekerSearchReason.CustomFormatScoreBelowCutoff; + _logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}", seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons)); @@ -783,7 +812,7 @@ public sealed class Seeker : IHandler SearchType = SeriesSearchType.Season }; - return (searchItem, selected.FirstEpisode); + return (searchItem, selected.FirstEpisode, searchReason); } private async Task UpdateSearchHistoryAsync( diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs index d63c5952..d16f772b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs @@ -152,8 +152,8 @@ public class SeekerCommandMonitor : BackgroundService else { // All completed — inspect download queue for grabbed items - object? resultData = await InspectDownloadQueueAsync(trackers, arrClientFactory); - await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData); + List? grabbedItems = await InspectDownloadQueueAsync(trackers, arrClientFactory); + await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, grabbedItems); _logger.LogDebug("Search command(s) completed for event {EventId}", eventId); } @@ -176,11 +176,11 @@ public class SeekerCommandMonitor : BackgroundService }; } - private async Task InspectDownloadQueueAsync( + private async Task?> InspectDownloadQueueAsync( List trackers, IArrClientFactory arrClientFactory) { - var allGrabbedItems = new List(); + var allGrabbedTitles = new List(); // Group by instance to inspect each instance's queue separately foreach (var instanceGroup in trackers.GroupBy(t => t.ArrInstanceId)) @@ -197,7 +197,7 @@ public class SeekerCommandMonitor : BackgroundService // Find records matching any tracker in this instance group foreach (var t in instanceGroup) { - var grabbedItems = queue.Records + var grabbedTitles = queue.Records .Where(r => t.ItemType == InstanceType.Radarr ? r.MovieId == t.ExternalItemId : r.SeriesId == t.ExternalItemId @@ -205,21 +205,16 @@ public class SeekerCommandMonitor : BackgroundService .Where(r => !string.IsNullOrEmpty(r.DownloadId)) .GroupBy(r => r.DownloadId) .Select(g => g.First()) - .Select(r => new - { - r.Title, - r.Status, - r.Protocol, - }) + .Select(r => r.Title) .ToList(); - if (grabbedItems.Count > 0) + if (grabbedTitles.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))); + t.ItemTitle, arrInstance.Name, grabbedTitles.Count, + string.Join(", ", grabbedTitles)); - allGrabbedItems.AddRange(grabbedItems); + allGrabbedTitles.AddRange(grabbedTitles); } } } @@ -229,6 +224,6 @@ public class SeekerCommandMonitor : BackgroundService } } - return allGrabbedItems.Count > 0 ? new { GrabbedItems = allGrabbedItems } : null; + return allGrabbedTitles.Count > 0 ? allGrabbedTitles : null; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs index 610de596..8988acb1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs @@ -12,5 +12,5 @@ public interface INotificationPublisher Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false); - Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable items); + Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index e34ff2a1..5a9ac085 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -82,11 +82,11 @@ public class NotificationPublisher : INotificationPublisher } } - public virtual async Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable items) + public virtual async Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason) { try { - var context = BuildSearchTriggeredContext(instanceName, itemCount, items); + var context = BuildSearchTriggeredContext(itemTitle, searchType, searchReason); await SendNotificationAsync(NotificationEventType.SearchTriggered, context); } catch (Exception ex) @@ -242,25 +242,24 @@ public class NotificationPublisher : INotificationPublisher return context; } - private static NotificationContext BuildSearchTriggeredContext(string instanceName, int itemCount, IEnumerable items) + private static NotificationContext BuildSearchTriggeredContext(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason) { 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}", + Description = $"Search triggered for {itemTitle}", Severity = EventSeverity.Information, Data = new Dictionary { ["Instance type"] = instanceType.ToString(), ["Url"] = instanceUrl.ToString(), - ["Item count"] = itemCount.ToString(), - ["Items"] = itemsDisplay, + ["Item"] = itemTitle, + ["Search type"] = searchType.ToString(), + ["Search reason"] = searchReason.ToString(), } }; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs new file mode 100644 index 00000000..ee19366c --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// Result of processing an arr instance for proactive search candidates. +/// +internal sealed record SeekerProcessResult +{ + public required List Candidates { get; init; } + public required List AllLibraryIds { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchCandidate.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchCandidate.cs new file mode 100644 index 00000000..20c72cce --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerSearchCandidate.cs @@ -0,0 +1,23 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// Represents a single item selected for proactive search. +/// +internal sealed record SeekerSearchCandidate +{ + /// + /// MovieId (Radarr) or SeriesId (Sonarr) + /// + public required long ItemId { get; init; } + + public required string Name { get; init; } + + /// + /// Season number for Sonarr; 0 for Radarr. + /// + public required int SeasonNumber { get; init; } + + public required SeekerSearchReason Reason { get; init; } +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index e64e51b7..69ceb2b9 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -378,6 +378,7 @@ public class DataContext : DbContext } var enumProperties = entityType.ClrType.GetProperties() + .Where(p => !p.IsDefined(typeof(System.ComponentModel.DataAnnotations.Schema.NotMappedAttribute), true)) .Where(p => p.PropertyType.IsEnum || (p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && diff --git a/code/backend/Cleanuparr.Persistence/EventsContext.cs b/code/backend/Cleanuparr.Persistence/EventsContext.cs index 76fea3d4..d3963b0d 100644 --- a/code/backend/Cleanuparr.Persistence/EventsContext.cs +++ b/code/backend/Cleanuparr.Persistence/EventsContext.cs @@ -22,6 +22,8 @@ public class EventsContext : DbContext public DbSet DownloadItems { get; set; } public DbSet JobRuns { get; set; } + + public DbSet SearchEventData { get; set; } public EventsContext() { @@ -65,6 +67,14 @@ public class EventsContext : DbContext .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity(entity => + { + entity.HasOne(s => s.AppEvent) + .WithOne(e => e.SearchEventData) + .HasForeignKey(s => s.AppEventId) + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity(entity => { entity.Property(e => e.CreatedAt) @@ -86,9 +96,10 @@ public class EventsContext : DbContext foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { var enumProperties = entityType.ClrType.GetProperties() - .Where(p => p.PropertyType.IsEnum || - (p.PropertyType.IsGenericType && - p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && + .Where(p => !p.IsDefined(typeof(System.ComponentModel.DataAnnotations.Schema.NotMappedAttribute), true)) + .Where(p => p.PropertyType.IsEnum || + (p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && p.PropertyType.GetGenericArguments()[0].IsEnum)); foreach (var property in enumProperties) diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20250614211246_InitialEvents.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20250614211246_InitialEvents.Designer.cs index 001a9903..20eb87f8 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Events/20250614211246_InitialEvents.Designer.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20250614211246_InitialEvents.Designer.cs @@ -2,7 +2,6 @@ #nullable disable -using Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.Designer.cs new file mode 100644 index 00000000..7752cb50 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.Designer.cs @@ -0,0 +1,454 @@ +// +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("20260405174732_AddSearchEventData")] + partial class AddSearchEventData + { + /// + 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("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_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("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + 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("ArrInstanceId") + .HasDatabaseName("ix_events_arr_instance_id"); + + b.HasIndex("CycleId") + .HasDatabaseName("ix_events_cycle_id"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_events_download_client_id"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_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.Events.SearchEventData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AppEventId") + .HasColumnType("TEXT") + .HasColumnName("app_event_id"); + + b.PrimitiveCollection("GrabbedItems") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grabbed_items"); + + b.Property("ItemTitle") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SearchReason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("search_reason"); + + b.Property("SearchType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.HasKey("Id") + .HasName("pk_search_event_data"); + + b.HasIndex("AppEventId") + .IsUnique() + .HasDatabaseName("ix_search_event_data_app_event_id"); + + b.ToTable("search_event_data", (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.Events.SearchEventData", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Events.AppEvent", "AppEvent") + .WithOne("SearchEventData") + .HasForeignKey("Cleanuparr.Persistence.Models.Events.SearchEventData", "AppEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_event_data_events_app_event_id"); + + b.Navigation("AppEvent"); + }); + + 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.Events.AppEvent", b => + { + b.Navigation("SearchEventData"); + }); + + 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/20260405174732_AddSearchEventData.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.cs new file mode 100644 index 00000000..100c728a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.cs @@ -0,0 +1,202 @@ +using System; +using Cleanuparr.Shared.Helpers; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddSearchEventData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "arr_instance_id", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "download_client_id", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "search_event_data", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + app_event_id = table.Column(type: "TEXT", nullable: false), + item_title = table.Column(type: "TEXT", maxLength: 500, nullable: false), + search_type = table.Column(type: "TEXT", nullable: false), + search_reason = table.Column(type: "TEXT", nullable: false), + grabbed_items = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_search_event_data", x => x.id); + table.ForeignKey( + name: "fk_search_event_data_events_app_event_id", + column: x => x.app_event_id, + principalTable: "events", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_events_arr_instance_id", + table: "events", + column: "arr_instance_id"); + + migrationBuilder.CreateIndex( + name: "ix_events_download_client_id", + table: "events", + column: "download_client_id"); + + migrationBuilder.CreateIndex( + name: "ix_search_event_data_app_event_id", + table: "search_event_data", + column: "app_event_id", + unique: true); + + string dataDbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db"); + + if (File.Exists(dataDbPath)) + { + migrationBuilder.Sql($""" + ATTACH DATABASE '{dataDbPath}' AS main_db; + + UPDATE events + SET arr_instance_id = ( + SELECT a.id FROM main_db.arr_instances a + WHERE RTRIM(a.url, '/') = RTRIM(events.instance_url, '/') + OR RTRIM(a.external_url, '/') = RTRIM(events.instance_url, '/') + LIMIT 1 + ) + WHERE instance_url IS NOT NULL AND arr_instance_id IS NULL; + + UPDATE events + SET download_client_id = ( + SELECT dc.id FROM main_db.download_clients dc + WHERE dc.name = events.download_client_name + LIMIT 1 + ) + WHERE download_client_name IS NOT NULL AND download_client_id IS NULL; + + DETACH DATABASE main_db; + """, suppressTransaction: true); + } + + migrationBuilder.Sql(""" + INSERT INTO search_event_data (id, app_event_id, item_title, search_type, search_reason, grabbed_items) + SELECT + lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(6))), + e.id, + COALESCE(json_extract(e.data, '$.Items[0]'), 'Unknown'), + COALESCE(LOWER(json_extract(e.data, '$.SearchType')), 'proactive'), + 'missing', + COALESCE( + (SELECT json_group_array(json_extract(value, '$.Title')) + FROM json_each(json_extract(e.data, '$.GrabbedItems'))), + '[]' + ) + FROM events e + WHERE e.event_type = 'searchtriggered' + AND e.data IS NOT NULL + AND e.data != ''; + """); + + migrationBuilder.Sql(""" + UPDATE events + SET data = NULL + WHERE event_type = 'searchtriggered'; + """); + + migrationBuilder.DropIndex( + name: "ix_events_instance_type", + table: "events"); + + migrationBuilder.DropIndex( + name: "ix_events_download_client_type", + table: "events"); + + migrationBuilder.DropColumn( + name: "instance_type", + table: "events"); + + migrationBuilder.DropColumn( + name: "instance_url", + table: "events"); + + migrationBuilder.DropColumn( + name: "download_client_type", + table: "events"); + + migrationBuilder.DropColumn( + name: "download_client_name", + table: "events"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "search_event_data"); + + migrationBuilder.DropIndex( + name: "ix_events_arr_instance_id", + table: "events"); + + migrationBuilder.DropIndex( + name: "ix_events_download_client_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "arr_instance_id", + table: "events"); + + migrationBuilder.DropColumn( + name: "download_client_id", + table: "events"); + + migrationBuilder.AddColumn( + name: "instance_type", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "instance_url", + table: "events", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "download_client_type", + table: "events", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "download_client_name", + table: "events", + type: "TEXT", + maxLength: 200, + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_events_instance_type", + table: "events", + column: "instance_type"); + + migrationBuilder.CreateIndex( + name: "ix_events_download_client_type", + table: "events", + column: "download_client_type"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs index 42b97625..71889f6a 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Events/EventsContextModelSnapshot.cs @@ -24,6 +24,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + b.Property("CompletedAt") .HasColumnType("TEXT") .HasColumnName("completed_at"); @@ -36,29 +40,15 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("data"); - b.Property("DownloadClientName") - .HasMaxLength(200) + b.Property("DownloadClientId") .HasColumnType("TEXT") - .HasColumnName("download_client_name"); - - b.Property("DownloadClientType") - .HasColumnType("TEXT") - .HasColumnName("download_client_type"); + .HasColumnName("download_client_id"); 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"); @@ -97,18 +87,18 @@ namespace Cleanuparr.Persistence.Migrations.Events b.HasKey("Id") .HasName("pk_events"); + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_events_arr_instance_id"); + b.HasIndex("CycleId") .HasDatabaseName("ix_events_cycle_id"); - b.HasIndex("DownloadClientType") - .HasDatabaseName("ix_events_download_client_type"); + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_events_download_client_id"); 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"); @@ -209,6 +199,48 @@ namespace Cleanuparr.Persistence.Migrations.Events b.ToTable("manual_events", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.SearchEventData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AppEventId") + .HasColumnType("TEXT") + .HasColumnName("app_event_id"); + + b.PrimitiveCollection("GrabbedItems") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grabbed_items"); + + b.Property("ItemTitle") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SearchReason") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("search_reason"); + + b.Property("SearchType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.HasKey("Id") + .HasName("pk_search_event_data"); + + b.HasIndex("AppEventId") + .IsUnique() + .HasDatabaseName("ix_search_event_data_app_event_id"); + + b.ToTable("search_event_data", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => { b.Property("Id") @@ -362,6 +394,18 @@ namespace Cleanuparr.Persistence.Migrations.Events b.Navigation("JobRun"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.SearchEventData", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Events.AppEvent", "AppEvent") + .WithOne("SearchEventData") + .HasForeignKey("Cleanuparr.Persistence.Models.Events.SearchEventData", "AppEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_event_data_events_app_event_id"); + + b.Navigation("AppEvent"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => { b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem") @@ -383,6 +427,11 @@ namespace Cleanuparr.Persistence.Migrations.Events b.Navigation("JobRun"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Navigation("SearchEventData"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => { b.Navigation("Strikes"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Arr/ArrInstance.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Arr/ArrInstance.cs index 3df0142b..48239872 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Arr/ArrInstance.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Arr/ArrInstance.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Cleanuparr.Shared.Attributes; namespace Cleanuparr.Persistence.Models.Configuration.Arr; @@ -26,4 +27,11 @@ public sealed class ArrInstance [SensitiveData] public required string ApiKey { get; set; } + + /// + /// Returns ExternalUrl if set, otherwise falls back to computed Url + /// + [NotMapped] + [JsonIgnore] + public Uri ExternalOrInternalUrl => ExternalUrl ?? Url; } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs index ca4a0fcc..e122e498 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Events/AppEvent.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.State; @@ -15,8 +16,8 @@ namespace Cleanuparr.Persistence.Models.Events; [Index(nameof(Message))] [Index(nameof(StrikeId))] [Index(nameof(JobRunId))] -[Index(nameof(InstanceType))] -[Index(nameof(DownloadClientType))] +[Index(nameof(ArrInstanceId))] +[Index(nameof(DownloadClientId))] [Index(nameof(CycleId))] public class AppEvent : IEvent { @@ -54,26 +55,14 @@ public class AppEvent : IEvent public JobRun? JobRun { get; set; } /// - /// The type of arr instance that generated this event (e.g., Sonarr, Radarr) + /// The ID of the arr instance that generated this event /// - public InstanceType? InstanceType { get; set; } + public Guid? ArrInstanceId { get; set; } /// - /// The URL of the arr instance that generated this event + /// The ID of the download client involved in this event /// - [MaxLength(500)] - public string? InstanceUrl { get; set; } - - /// - /// The type of download client involved in this event - /// - public DownloadClientTypeName? DownloadClientType { get; set; } - - /// - /// The name of the download client involved in this event - /// - [MaxLength(200)] - public string? DownloadClientName { get; set; } + public Guid? DownloadClientId { get; set; } /// /// Status of the search command (only set for SearchTriggered events) @@ -91,4 +80,20 @@ public class AppEvent : IEvent public Guid? CycleId { get; set; } public bool IsDryRun { get; set; } -} \ No newline at end of file + + public SearchEventData? SearchEventData { get; set; } + + // Used only for notifications + + [NotMapped] + public InstanceType? InstanceType { get; set; } + + [NotMapped] + public string? InstanceUrl { get; set; } + + [NotMapped] + public DownloadClientTypeName? DownloadClientType { get; set; } + + [NotMapped] + public string? DownloadClientName { get; set; } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Events/SearchEventData.cs b/code/backend/Cleanuparr.Persistence/Models/Events/SearchEventData.cs new file mode 100644 index 00000000..894f69e6 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Events/SearchEventData.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Persistence.Models.Events; + +/// +/// Stores structured data for SearchTriggered events. +/// One record per searched item. +/// +public class SearchEventData +{ + [Key] + public Guid Id { get; set; } = Guid.CreateVersion7(); + + public Guid AppEventId { get; set; } + + [JsonIgnore] + public AppEvent AppEvent { get; set; } = null!; + + [MaxLength(500)] + public string ItemTitle { get; set; } = string.Empty; + + public SeekerSearchType SearchType { get; set; } + + public SeekerSearchReason SearchReason { get; set; } + + /// + /// Titles of items grabbed after search completion, populated by SeekerCommandMonitor. + /// + public List GrabbedItems { get; set; } = []; +} diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index 63d89def..2ef8554e 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -27,17 +27,24 @@ export enum SeekerSearchType { Replacement = 'Replacement', } +export enum SeekerSearchReason { + Missing = 'Missing', + QualityCutoffNotMet = 'QualityCutoffNotMet', + CustomFormatScoreBelowCutoff = 'CustomFormatScoreBelowCutoff', + Replacement = 'Replacement', +} + export interface SearchEvent { id: string; timestamp: string; - instanceName: string; + arrInstanceId: string | null; instanceType: string | null; - itemCount: number; - items: string[]; + itemTitle: string; searchType: SeekerSearchType; + searchReason: string | null; searchStatus: string | null; completedAt: string | null; - grabbedItems: unknown[] | null; + grabbedItems: string[] | null; cycleId: string | null; isDryRun: boolean; } 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 index 39891231..e0c0e2a5 100644 --- 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 @@ -81,7 +81,6 @@ (click)="toggleExpand(item)" > - {{ item.title }} {{ item.currentScore }} / @@ -103,6 +102,9 @@ class="score-row__chevron" /> +
+ {{ item.title }} +
@if (expandedId() === item.id) {
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 index ec03e2d5..4637eb91 100644 --- 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 @@ -115,9 +115,9 @@ &__title { font-weight: 500; color: var(--text-primary); - min-width: 0; - flex: 1; + padding: 0 var(--space-4) var(--space-2); word-break: break-word; + cursor: pointer; } &__scores { @@ -276,12 +276,11 @@ padding: var(--space-2) var(--space-3); } + .score-row__title { + padding: 0 var(--space-3) var(--space-2); + } + .score-row__profile { display: none; } - - .score-row__time { - order: 4; - flex-basis: 100%; - } } 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 index c4cca434..1f1714d7 100644 --- 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 @@ -135,15 +135,14 @@
- - {{ event.items.length > 0 ? event.items[0] : 'Search triggered' }} - @if (event.items.length > 1) { - +{{ event.items.length - 1 }} more - } - {{ event.searchType }} + @if (event.searchReason) { + + {{ formatSearchReason(event.searchReason) }} + + } @if (event.searchStatus) { {{ event.searchStatus }} @@ -155,9 +154,14 @@ @if (event.cycleId) { {{ event.cycleId.substring(0, 8) }} } - {{ event.instanceName }} + @if (event.instanceType) { + {{ event.instanceType }} + } {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}
+
+ {{ event.itemTitle || 'Search triggered' }} +
@if (event.grabbedItems && event.grabbedItems.length > 0) {
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 index f87daaeb..e0c04090 100644 --- 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 @@ -264,8 +264,7 @@ &__title { font-weight: 500; color: var(--text-primary); - min-width: 0; - flex: 1; + padding: 0 var(--space-4) var(--space-2); word-break: break-word; } @@ -276,16 +275,6 @@ 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); @@ -306,7 +295,7 @@ 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)); + padding: 0 var(--space-4) var(--space-2); font-size: var(--font-size-xs); color: var(--text-tertiary); } @@ -329,7 +318,7 @@ flex-wrap: wrap; } - .list-row__meta { + .list-row__instance { display: none; } } @@ -350,12 +339,11 @@ padding: var(--space-2) var(--space-3); } - .list-row__meta { - display: none; + .list-row__title { + padding: 0 var(--space-3) var(--space-2); } - .list-row__time { - order: 4; - flex-basis: 100%; + .list-row__instance { + display: none; } } 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 index b2895f3a..e8d86b72 100644 --- 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 @@ -10,7 +10,7 @@ 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 { SeekerSearchType, SeekerSearchReason } from '@core/models/search-stats.models'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; @@ -141,8 +141,28 @@ export class SearchesTabComponent implements OnInit { } } - formatGrabbedItems(items: unknown[]): string { - return items.map((i: any) => i.Title || i.title || 'Unknown').join(', '); + formatGrabbedItems(items: string[]): string { + return items.join(', '); + } + + formatSearchReason(reason: string): string { + switch (reason) { + case SeekerSearchReason.Missing: return 'Missing'; + case SeekerSearchReason.QualityCutoffNotMet: return 'Cutoff Unmet'; + case SeekerSearchReason.CustomFormatScoreBelowCutoff: return 'CF Below Cutoff'; + case SeekerSearchReason.Replacement: return 'Replacement'; + default: return reason; + } + } + + searchReasonSeverity(reason: string): BadgeSeverity { + switch (reason) { + case SeekerSearchReason.Missing: return 'error'; + case SeekerSearchReason.QualityCutoffNotMet: return 'warning'; + case SeekerSearchReason.CustomFormatScoreBelowCutoff: return 'warning'; + case SeekerSearchReason.Replacement: return 'info'; + default: return 'default'; + } } cycleProgress(inst: InstanceSearchStat): number { 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 index d975b9fb..5f65241d 100644 --- 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 @@ -35,7 +35,6 @@
- {{ upgrade.title }}
{{ upgrade.previousScore }} @@ -47,6 +46,9 @@ {{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }}
+
+ {{ upgrade.title }} +
} @empty {