Add search event reason (#546)

This commit is contained in:
Flaminel
2026-04-06 09:59:31 +03:00
committed by GitHub
parent 88aa71c343
commit 80b46df8e5
81 changed files with 3438 additions and 1016 deletions

View File

@@ -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<string>? 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

View File

@@ -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)
);
}

View File

@@ -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)
);
}

View File

@@ -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;

View File

@@ -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<string> 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<string> GrabbedItems { get; init; } = [];
public Guid? CycleId { get; init; }
public bool IsDryRun { get; init; }
}

View File

@@ -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
}
/// <summary>
/// Gets paginated search-triggered events with decoded data.
/// Supports optional text search across item names in event data.
/// Gets paginated search-triggered events
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> 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<Guid, InstanceType>();
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<string> 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<string>();
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<SeekerSearchType>(typeEl.GetString(), out var parsed)
? parsed
: SeekerSearchType.Proactive;
object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
? JsonSerializer.Deserialize<object>(grabbedEl.GetRawText())
: null;
return (instanceName, itemCount, items, searchType, grabbedItems);
}
catch (JsonException)
{
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
}
}
}

View File

@@ -1,4 +1,4 @@
namespace Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;
public class SearchItem
{

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Enums;
using Data.Models.Arr;
namespace Cleanuparr.Domain.Entities.Arr;

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerSearchReason
{
Missing,
QualityCutoffNotMet,
CustomFormatScoreBelowCutoff,
Replacement,
}

View File

@@ -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<IHubContext<AppHub>> _hubContextMock;
private readonly Mock<ILogger<EventPublisher>> _loggerMock;
private readonly Mock<INotificationPublisher> _notificationPublisherMock;
private readonly Mock<IDryRunInterceptor> _dryRunInterceptorMock;
private readonly Mock<IClientProxy> _clientProxyMock;
private readonly IHubContext<AppHub> _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<EventsContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
_context = new EventsContext(options);
// Setup mocks
_hubContextMock = new Mock<IHubContext<AppHub>>();
_loggerMock = new Mock<ILogger<EventPublisher>>();
_notificationPublisherMock = new Mock<INotificationPublisher>();
_dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
_clientProxyMock = new Mock<IClientProxy>();
_hubContext = Substitute.For<IHubContext<AppHub>>();
_notificationPublisher = Substitute.For<INotificationPublisher>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_clientProxy = Substitute.For<IClientProxy>();
// Setup HubContext to return client proxy
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(_clientProxyMock.Object);
_hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var clients = Substitute.For<IHubClients>();
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<ILogger<EventPublisher>>(),
_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<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
[Fact]
@@ -151,10 +150,10 @@ public class EventPublisherTests : IDisposable
var message = "Test message";
var severity = EventSeverity.Important;
_clientProxyMock.Setup(c => c.SendCoreAsync(
It.IsAny<string>(),
It.IsAny<object[]>(),
It.IsAny<CancellationToken>()))
_clientProxy.SendCoreAsync(
Arg.Any<string>(),
Arg.Any<object[]>(),
Arg.Any<CancellationToken>())
.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<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is ManualEvent),
Arg.Any<CancellationToken>());
}
#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<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
[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<IEnumerable<string>>()),
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<string> { "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<object[]>(args => args.Length == 1 && args[0] is AppEvent),
It.IsAny<CancellationToken>()), Times.Once);
Arg.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
Arg.Any<CancellationToken>());
}
#endregion

View File

@@ -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<SearchItem>
{
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<SearchItem>
{
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<SearchItem>
{
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<QueueItemRemoveRequest<SearchItem>>(req => req.InstanceType == InstanceType.Readarr)), Times.Once);
It.Is<QueueItemRemoveRequest<SearchItem>>(req => req.Instance.ArrConfig.Type == InstanceType.Readarr)), Times.Once);
}
#endregion
@@ -189,8 +186,7 @@ public class DownloadRemoverConsumerTests
{
return new QueueItemRemoveRequest<SearchItem>
{
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 }
};
}

View File

@@ -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<ILogger<QueueItemRemover>> _loggerMock;
private readonly ILogger<QueueItemRemover> _logger;
private readonly MemoryCache _memoryCache;
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
private readonly Mock<IArrClient> _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<ILogger<QueueItemRemover>>();
_logger = Substitute.For<ILogger<QueueItemRemover>>();
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_arrClientFactoryMock = new Mock<IArrClientFactory>();
_arrClientMock = new Mock<IArrClient>();
_arrClientFactory = Substitute.For<IArrClientFactory>();
_arrClient = Substitute.For<IArrClient>();
_arrClientFactoryMock
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
.Returns(_arrClientMock.Object);
_arrClientFactory
.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>())
.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<IHubContext<AppHub>>();
var clientsMock = new Mock<IHubClients>();
clientsMock.Setup(c => c.All).Returns(Mock.Of<IClientProxy>());
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
var hubContext = Substitute.For<IHubContext<AppHub>>();
var clients = Substitute.For<IHubClients>();
clients.All.Returns(Substitute.For<IClientProxy>());
hubContext.Clients.Returns(clients);
var dryRunInterceptorMock = new Mock<IDryRunInterceptor>();
dryRunInterceptorMock.Setup(d => d.IsDryRunEnabled()).ReturnsAsync(false);
var dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
dryRunInterceptor.IsDryRunEnabled().Returns(false);
// Setup interceptor for other uses (e.g., ArrClient deletion)
dryRunInterceptorMock
.Setup(d => d.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
dryRunInterceptor
.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>())
.Returns(Task.CompletedTask);
_eventPublisher = new EventPublisher(
_eventsContext,
hubContextMock.Object,
Mock.Of<ILogger<EventPublisher>>(),
Mock.Of<INotificationPublisher>(),
dryRunInterceptorMock.Object);
hubContext,
Substitute.For<ILogger<EventPublisher>>(),
Substitute.For<INotificationPublisher>(),
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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
_arrClientFactory.Received(1).GetClient(instanceType, Arg.Any<float>());
}
#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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
var exception = await Assert.ThrowsAsync<Exception>(
var exception = await Should.ThrowAsync<Exception>(
() => _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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
await Should.ThrowAsync<Exception>(
() => _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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<HttpRequestException>(
var exception = await Should.ThrowAsync<HttpRequestException>(
() => _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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
_arrClient
.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
var exception = await Should.ThrowAsync<InvalidOperationException>(
() => _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<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
deleteReason), Times.Once);
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
deleteReason);
}
[Theory]
@@ -487,23 +401,15 @@ public class QueueItemRemoverTests : IDisposable
// Arrange
var request = CreateRemoveRequest(removeFromClient: removeFromClient);
_arrClientMock
.Setup(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
It.IsAny<bool>(),
It.IsAny<DeleteReason>()))
.Returns(Task.CompletedTask);
// Act
await _queueItemRemover.RemoveQueueItemAsync(request);
// Assert
_arrClientMock.Verify(c => c.DeleteQueueItemAsync(
It.IsAny<ArrInstance>(),
It.IsAny<QueueRecord>(),
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
removeFromClient,
It.IsAny<DeleteReason>()), Times.Once);
Arg.Any<DeleteReason>());
}
#endregion
@@ -521,7 +427,6 @@ public class QueueItemRemoverTests : IDisposable
return new QueueItemRemoveRequest<SearchItem>
{
InstanceType = instanceType,
Instance = instance,
SearchItem = new SearchItem { Id = 123 },
Record = CreateQueueRecord(),

View File

@@ -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<ILogger<DownloadCleaner>>(),
_fixture.DataContext,
_fixture.Cache,
_fixture.MessageBus,
_fixture.ArrClientFactory,
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.EventPublisher,
_fixture.TimeProvider,
_fixture.HardLinkFileService);
}
/// <summary>
/// Creates a mock download service that uses the actual DB config (so seeding rules match by ID).
/// </summary>
private static IDownloadService CreateMockDownloadServiceWithDbConfig(DownloadClientConfig dbConfig)
{
var mock = Substitute.For<IDownloadService>();
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<DownloadClientConfig>())
.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<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == orphanedHash),
Arg.Any<List<ISeedingRule>>());
}
[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<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
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<ITorrentItemWrapper>>(list =>
list.Count == 1 && list[0].Hash == "normal_hash"),
Arg.Any<List<ISeedingRule>>());
}
[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<DownloadClientConfig>());
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<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
// Configure CleanDownloadsAsync to simulate what real DownloadService does:
// set ContextProvider keys and call real EventPublisher
mockDownloadService.CleanDownloadsAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<List<ISeedingRule>>())
.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<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
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<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.Returns(ci => ci.Arg<List<ITorrentItemWrapper>>());
// Configure ChangeCategoryForNoHardLinksAsync to simulate what real DownloadService does
mockDownloadService.ChangeCategoryForNoHardLinksAsync(
Arg.Any<List<ITorrentItemWrapper>>(), Arg.Any<UnlinkedConfig>())
.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<DownloadClientConfig>())
.Returns(mockDownloadService);
// No arr-managed downloads
_fixture.ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(Array.Empty<QueueRecord>());
});
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<ITorrentItemWrapper>();
mock.Hash.Returns(hash);
mock.Name.Returns(name);
mock.Category.Returns(category);
mock.IsIgnored(Arg.Any<List<string>>()).Returns(ci =>
{
var ignoredList = ci.Arg<List<string>>();
return ignoredList.Contains(name, StringComparer.InvariantCultureIgnoreCase);
});
return mock;
}
}

View File

@@ -0,0 +1,9 @@
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration;
[CollectionDefinition(Name)]
public class IntegrationTestCollection : ICollectionFixture<IntegrationTestFixture>
{
public const string Name = "JobHandlerIntegration";
}

View File

@@ -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;
/// <summary>
/// Shared fixture for integration tests that wires up real services (EventPublisher, QueueItemRemover)
/// with NSubstitute mocks at external boundaries (Arr clients, download clients, notifications).
/// </summary>
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<AppHub> HubContext { get; private set; }
// State
public Guid JobRunId { get; private set; }
public List<object> CapturedMessages { get; } = [];
public IntegrationTestFixture()
{
DataContext = TestDataContextFactory.Create();
EventsContext = TestEventsContextFactory.Create();
Cache = new MemoryCache(new MemoryCacheOptions());
TimeProvider = new FakeTimeProvider();
MessageBus = Substitute.For<IBus>();
ArrClientFactory = Substitute.For<IArrClientFactory>();
ArrClient = Substitute.For<IArrClient>();
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
NotificationPublisher = Substitute.For<INotificationPublisher>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HubContext = CreateMockHubContext();
SetupDefaults();
BuildRealServices();
}
private void SetupDefaults()
{
// ArrClientFactory returns the shared ArrClient mock by default
ArrClientFactory.GetClient(Arg.Any<InstanceType>(), Arg.Any<float>()).Returns(ArrClient);
// DryRunInterceptor returns false (not dry run) by default
DryRunInterceptor.IsDryRunEnabled().Returns(false);
DryRunInterceptor.InterceptAsync(Arg.Any<Delegate>(), Arg.Any<object[]>()).Returns(Task.CompletedTask);
// Capture messages published to IBus (generic Publish<T> overloads)
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SearchItem>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask)
.AndDoes(ci => CapturedMessages.Add(ci[0]));
MessageBus.Publish(Arg.Any<QueueItemRemoveRequest<SeriesSearchItem>>(), Arg.Any<CancellationToken>())
.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<ILogger<EventPublisher>>(),
NotificationPublisher,
DryRunInterceptor);
// Expose EventPublisher as both concrete and interface
EventPublisherInterface = EventPublisher;
Striker = new Striker(
Substitute.For<ILogger<Striker>>(),
EventsContext,
EventPublisher,
DryRunInterceptor);
QueueItemRemover = new QueueItemRemover(
Substitute.For<ILogger<QueueItemRemover>>(),
Cache,
ArrClientFactory,
EventPublisher,
EventsContext,
DataContext);
}
/// <summary>
/// Gets distinct remove requests from captured messages (NSubstitute may capture duplicates
/// when both generic type setups match).
/// </summary>
public List<object> GetCapturedRemoveRequests()
{
return CapturedMessages
.Where(m => m is QueueItemRemoveRequest<SearchItem> or QueueItemRemoveRequest<SeriesSearchItem>)
.DistinctBy(m => m switch
{
QueueItemRemoveRequest<SearchItem> r => r.Record.DownloadId,
QueueItemRemoveRequest<SeriesSearchItem> r => r.Record.DownloadId,
_ => ""
})
.ToList();
}
/// <summary>
/// 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.
/// </summary>
public async Task ProcessCapturedRemoveRequestsAsync()
{
foreach (var message in GetCapturedRemoveRequests())
{
switch (message)
{
case QueueItemRemoveRequest<SearchItem> request:
await QueueItemRemover.RemoveQueueItemAsync(request);
break;
case QueueItemRemoveRequest<SeriesSearchItem> request:
await QueueItemRemover.RemoveQueueItemAsync(request);
break;
}
}
}
/// <summary>
/// Configures the IArrQueueIterator to invoke the callback with the given records
/// when Iterate is called for any instance.
/// </summary>
public void SetupArrQueueIterator(params QueueRecord[] records)
{
ArrQueueIterator.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>())
.Returns(ci =>
{
var callback = ci.Arg<Func<IReadOnlyList<QueueRecord>, Task>>();
return callback(records);
});
}
/// <summary>
/// Creates a NSubstitute IDownloadService mock with default configuration.
/// </summary>
public IDownloadService CreateMockDownloadService(
string clientName = "Test qBittorrent",
DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent,
DownloadClientType type = DownloadClientType.Torrent)
{
var mock = Substitute.For<IDownloadService>();
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;
}
/// <summary>
/// Registers mock download services with the factory, matched by their ClientConfig.
/// </summary>
public void SetupDownloadServices(params IDownloadService[] services)
{
foreach (var service in services)
{
DownloadServiceFactory.GetDownloadService(service.ClientConfig).Returns(service);
}
}
/// <summary>
/// Recreates DataContext, EventsContext, cache, and resets all mocks for a clean test.
/// </summary>
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<IBus>();
ArrClientFactory = Substitute.For<IArrClientFactory>();
ArrClient = Substitute.For<IArrClient>();
ArrQueueIterator = Substitute.For<IArrQueueIterator>();
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
NotificationPublisher = Substitute.For<INotificationPublisher>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
HubContext = CreateMockHubContext();
// Re-setup defaults and rebuild real services
SetupDefaults();
BuildRealServices();
// Clear static state
Striker.RecurringHashes.Clear();
}
private static IHubContext<AppHub> CreateMockHubContext()
{
var hubContext = Substitute.For<IHubContext<AppHub>>();
var clients = Substitute.For<IHubClients>();
var clientProxy = Substitute.For<IClientProxy>();
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);
}
}

View File

@@ -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<ILogger<MalwareBlockerJob>>(),
_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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
// Assert: Arr client was told to delete with AllFilesBlocked reason
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new BlockFilesResult
{
Found = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = true
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
await _fixture.ArrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
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 = []
};
}
}

View File

@@ -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<ILogger<QueueCleanerJob>>(),
_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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = true,
Found = true,
DeleteReason = DeleteReason.Stalled,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClient.ShouldRemoveFromQueue(
Arg.Any<InstanceType>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<short>())
.Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = false,
Found = true,
IsPrivate = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: failed import removal published
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).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<bool>(), Arg.Any<DeleteReason>());
}
[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<QueueRecord>()).Returns(true);
_fixture.ArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService.ShouldRemoveFromArrQueueAsync(Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>())
.Returns(new DownloadCheckResult
{
ShouldRemove = true,
Found = true,
DeleteReason = DeleteReason.Stalled,
IsPrivate = true,
DeleteFromClient = false
});
_fixture.DownloadServiceFactory.GetDownloadService(Arg.Any<Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig>())
.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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.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<ArrInstance>(),
Arg.Any<QueueRecord>(),
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 = []
};
}
}

View File

@@ -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<IHostingEnvironment>();
environment.EnvironmentName.Returns("Development");
return new SeekerJob(
Substitute.For<ILogger<SeekerJob>>(),
_fixture.DataContext,
Substitute.For<IRadarrClient>(),
Substitute.For<ISonarrClient>(),
_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<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(), Arg.Any<HashSet<SearchItem>>())
.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<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
Arg.Any<HashSet<SearchItem>>());
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<string>(), Arg.Any<SeekerSearchType>(), Arg.Any<SeekerSearchReason>());
// 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<Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance>(),
Arg.Any<HashSet<SearchItem>>())
.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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<QueueItemRemoveRequest<SearchItem>>(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<QueueItemRemoveRequest<SearchItem>>(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<QueueItemRemoveRequest<SearchItem>>(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<QueueItemRemoveRequest<SeriesSearchItem>>(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<QueueItemRemoveRequest<SearchItem>>(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<QueueItemRemoveRequest<SeriesSearchItem>>(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 &&

View File

@@ -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<object?>();
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<object?>())
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<object?>(2)));
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(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<object?>());
eventId, SearchCommandStatus.Completed, Arg.Any<List<string>?>());
resultData.ShouldNotBeNull();
GetGrabbedItems(resultData).GetArrayLength().ShouldBe(1);
resultData!.Count.ShouldBe(1);
}
[Fact]
@@ -158,11 +157,11 @@ public class SeekerCommandMonitorTests : IAsyncDisposable
]
});
var publishTcs = new TaskCompletionSource<object?>();
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<object?>())
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<object?>(2)));
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(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<object?>();
var publishTcs = new TaskCompletionSource<List<string>?>();
_eventPublisher.PublishSearchCompleted(
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<object?>())
Arg.Any<Guid>(), Arg.Any<SearchCommandStatus>(), Arg.Any<List<string>?>())
.Returns(Task.CompletedTask)
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<object?>(2)));
.AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt<List<string>?>(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();
}
}

View File

@@ -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<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<SeekerSearchReason>(),
It.IsAny<Guid?>()))
.ReturnsAsync(Guid.NewGuid());
}
@@ -119,9 +117,8 @@ public class SeekerTests : IDisposable
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<SeekerSearchReason>(),
It.IsAny<Guid?>()),
Times.Never);
}
@@ -147,9 +144,8 @@ public class SeekerTests : IDisposable
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<SeekerSearchReason>(),
It.IsAny<Guid?>()),
Times.Never);
}
@@ -191,10 +187,9 @@ public class SeekerTests : IDisposable
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
radarrInstance.Name,
1,
It.Is<IEnumerable<string>>(items => items.Contains("Test Movie")),
"Test Movie",
SeekerSearchType.Replacement,
SeekerSearchReason.Replacement,
It.IsAny<Guid?>()),
Times.Once);
@@ -294,9 +289,8 @@ public class SeekerTests : IDisposable
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
SeekerSearchType.Proactive,
It.IsAny<SeekerSearchReason>(),
It.IsAny<Guid?>()),
Times.Never);
}
@@ -961,9 +955,8 @@ public class SeekerTests : IDisposable
_fixture.EventPublisher.Verify(
x => x.PublishSearchTriggered(
It.IsAny<string>(),
It.IsAny<int>(),
It.IsAny<IEnumerable<string>>(),
It.IsAny<SeekerSearchType>(),
It.IsAny<SeekerSearchReason>(),
It.IsAny<Guid?>()),
Times.Never);
}

View File

@@ -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<DynamicHttpClientProvider> Logger { get; }
//
// public DynamicHttpClientProviderFixture()
// {
// Logger = Substitute.For<ILogger<DynamicHttpClientProvider>>();
// }
//
// public DynamicHttpClientProvider CreateSut()
// {
// var httpClientFactory = Substitute.For<IHttpClientFactory>();
// var configManager = Substitute.For<IConfigManager>();
// var certificateValidationService = Substitute.For<CertificateValidationService>();
//
// 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
// }
// }

View File

@@ -1,133 +0,0 @@
// using System.Net;
// using Common.Enums;
// using Infrastructure.Http;
// using Shouldly;
//
// namespace Infrastructure.Tests.Http;
//
// public class DynamicHttpClientProviderTests : IClassFixture<DynamicHttpClientProviderFixture>
// {
// 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();
// }
// }

View File

@@ -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.
/// </summary>
public async Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null)
public async Task<Guid> 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;
}
/// <summary>
/// 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
/// </summary>
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null)
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, List<string>? 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<Dictionary<string, object>>(existingEvent.Data)
: new Dictionary<string, object>();
var resultJson = JsonSerializer.Serialize(resultData, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
});
var resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(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

View File

@@ -20,7 +20,7 @@ public interface IEventPublisher
Task PublishSearchNotTriggered(string hash, string itemName);
Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null);
Task<Guid> 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<string>? grabbedItems = null);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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";
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -47,6 +47,7 @@ public partial class QBitService
result.IsPrivate = isPrivate;
result.Found = true;
SetDownloadClientContext();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<RTorrentFile> files;
try

View File

@@ -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);

View File

@@ -38,7 +38,8 @@ public partial class TransmissionService
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
SetDownloadClientContext();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && isPrivate)

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<T>
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<T>
public required Guid JobRunId { get; init; }
public bool SkipSearch { get; init; }
}
public DownloadClientConfig? DownloadClient { get; init; }
}

View File

@@ -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
{

View File

@@ -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<SeriesSearchItem> 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<SearchItem> 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 });

View File

@@ -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<IDownloadService> 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
);
}
});

View File

@@ -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<IDownloadService> 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;

View File

@@ -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<long> 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<SearchItem> searchItems;
List<string> selectedNames;
List<long> allLibraryIds;
List<long> 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<long> 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<long> 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<long> 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<long> SelectedIds, List<string> SelectedNames, List<long> AllLibraryIds)> ProcessRadarrAsync(
private async Task<SeekerProcessResult> 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<long> selectedIds = selector.Select(selectionCandidates, 1);
List<string> selectedNames = candidates
.Where(m => selectedIds.Contains(m.Id))
.Select(m => m.Title)
.ToList();
List<SeekerSearchCandidate> 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<SearchItem> SearchItems, List<string> SelectedNames, List<long> AllLibraryIds, List<long> HistoryIds, int SeasonNumber)> ProcessSonarrAsync(
private async Task<SeekerProcessResult> 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<SeekerHistory> 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<long, DateTime>(), [], isDryRun, isRetry: true, queuedSeasons: queuedSeasons);
}
return ([], [], allLibraryIds, [], 0);
return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds };
}
/// <summary>
/// Fetches episodes for a series and builds a season-level search item.
/// Uses search history to prefer least-recently-searched seasons.
/// </summary>
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(

View File

@@ -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<string>? 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<object?> InspectDownloadQueueAsync(
private async Task<List<string>?> InspectDownloadQueueAsync(
List<SeekerCommandTracker> trackers,
IArrClientFactory arrClientFactory)
{
var allGrabbedItems = new List<object>();
var allGrabbedTitles = new List<string>();
// 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;
}
}

View File

@@ -12,5 +12,5 @@ public interface INotificationPublisher
Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> items);
Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason);
}

View File

@@ -82,11 +82,11 @@ public class NotificationPublisher : INotificationPublisher
}
}
public virtual async Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> 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<string> items)
private static NotificationContext BuildSearchTriggeredContext(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason)
{
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
var instanceUrl = ContextProvider.Get<Uri>(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<string, string>
{
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
["Item count"] = itemCount.ToString(),
["Items"] = itemsDisplay,
["Item"] = itemTitle,
["Search type"] = searchType.ToString(),
["Search reason"] = searchReason.ToString(),
}
};
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Infrastructure.Features.Seeker;
/// <summary>
/// Result of processing an arr instance for proactive search candidates.
/// </summary>
internal sealed record SeekerProcessResult
{
public required List<SeekerSearchCandidate> Candidates { get; init; }
public required List<long> AllLibraryIds { get; init; }
}

View File

@@ -0,0 +1,23 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Seeker;
/// <summary>
/// Represents a single item selected for proactive search.
/// </summary>
internal sealed record SeekerSearchCandidate
{
/// <summary>
/// MovieId (Radarr) or SeriesId (Sonarr)
/// </summary>
public required long ItemId { get; init; }
public required string Name { get; init; }
/// <summary>
/// Season number for Sonarr; 0 for Radarr.
/// </summary>
public required int SeasonNumber { get; init; }
public required SeekerSearchReason Reason { get; init; }
}

View File

@@ -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<>) &&

View File

@@ -22,6 +22,8 @@ public class EventsContext : DbContext
public DbSet<DownloadItem> DownloadItems { get; set; }
public DbSet<JobRun> JobRuns { get; set; }
public DbSet<SearchEventData> SearchEventData { get; set; }
public EventsContext()
{
@@ -65,6 +67,14 @@ public class EventsContext : DbContext
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<SearchEventData>(entity =>
{
entity.HasOne(s => s.AppEvent)
.WithOne(e => e.SearchEventData)
.HasForeignKey<SearchEventData>(s => s.AppEventId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Strike>(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)

View File

@@ -2,7 +2,6 @@
#nullable disable
using Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@@ -0,0 +1,454 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid?>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<Guid?>("CycleId")
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<Guid?>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("SearchStatus")
.HasColumnType("TEXT")
.HasColumnName("search_status");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<Guid?>("StrikeId")
.HasColumnType("TEXT")
.HasColumnName("strike_id");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("AppEventId")
.HasColumnType("TEXT")
.HasColumnName("app_event_id");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<string>("ItemTitle")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<string>("SearchReason")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("INTEGER")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("INTEGER")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("INTEGER")
.HasColumnName("is_returning");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<Guid>("DownloadItemId")
.HasColumnType("TEXT")
.HasColumnName("download_item_id");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<Guid>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<long?>("LastDownloadedBytes")
.HasColumnType("INTEGER")
.HasColumnName("last_downloaded_bytes");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,202 @@
using System;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class AddSearchEventData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "arr_instance_id",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "download_client_id",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "search_event_data",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
app_event_id = table.Column<Guid>(type: "TEXT", nullable: false),
item_title = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
search_type = table.Column<string>(type: "TEXT", nullable: false),
search_reason = table.Column<string>(type: "TEXT", nullable: false),
grabbed_items = table.Column<string>(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");
}
/// <inheritdoc />
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<string>(
name: "instance_type",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "instance_url",
table: "events",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "download_client_type",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
}
}

View File

@@ -24,6 +24,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid?>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
@@ -36,29 +40,15 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
b.Property<Guid?>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
.HasColumnName("download_client_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("AppEventId")
.HasColumnType("TEXT")
.HasColumnName("app_event_id");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<string>("ItemTitle")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<string>("SearchReason")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("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<Guid>("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");

View File

@@ -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; }
/// <summary>
/// Returns ExternalUrl if set, otherwise falls back to computed Url
/// </summary>
[NotMapped]
[JsonIgnore]
public Uri ExternalOrInternalUrl => ExternalUrl ?? Url;
}

View File

@@ -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; }
/// <summary>
/// The type of arr instance that generated this event (e.g., Sonarr, Radarr)
/// The ID of the arr instance that generated this event
/// </summary>
public InstanceType? InstanceType { get; set; }
public Guid? ArrInstanceId { get; set; }
/// <summary>
/// The URL of the arr instance that generated this event
/// The ID of the download client involved in this event
/// </summary>
[MaxLength(500)]
public string? InstanceUrl { get; set; }
/// <summary>
/// The type of download client involved in this event
/// </summary>
public DownloadClientTypeName? DownloadClientType { get; set; }
/// <summary>
/// The name of the download client involved in this event
/// </summary>
[MaxLength(200)]
public string? DownloadClientName { get; set; }
public Guid? DownloadClientId { get; set; }
/// <summary>
/// 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; }
}
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; }
}

View File

@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Events;
/// <summary>
/// Stores structured data for SearchTriggered events.
/// One record per searched item.
/// </summary>
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; }
/// <summary>
/// Titles of items grabbed after search completion, populated by SeekerCommandMonitor.
/// </summary>
public List<string> GrabbedItems { get; set; } = [];
}

View File

@@ -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;
}

View File

@@ -81,7 +81,6 @@
(click)="toggleExpand(item)"
>
<ng-icon name="tablerChartBar" class="score-row__icon" />
<span class="score-row__title">{{ item.title }}</span>
<span class="score-row__scores">
<span class="score-row__current">{{ item.currentScore }}</span>
<span class="score-row__separator">/</span>
@@ -103,6 +102,9 @@
class="score-row__chevron"
/>
</div>
<div class="score-row__title" (click)="toggleExpand(item)">
{{ item.title }}
</div>
@if (expandedId() === item.id) {
<div class="score-row__details">

View File

@@ -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%;
}
}

View File

@@ -135,15 +135,14 @@
<div class="list-row">
<div class="list-row__main">
<ng-icon name="tablerSearch" class="list-row__icon" />
<span class="list-row__title">
{{ event.items.length > 0 ? event.items[0] : 'Search triggered' }}
@if (event.items.length > 1) {
<span class="list-row__extra">+{{ event.items.length - 1 }} more</span>
}
</span>
<app-badge [severity]="searchTypeSeverity(event.searchType)" size="sm">
{{ event.searchType }}
</app-badge>
@if (event.searchReason) {
<app-badge [severity]="searchReasonSeverity(event.searchReason)" size="sm">
{{ formatSearchReason(event.searchReason) }}
</app-badge>
}
@if (event.searchStatus) {
<app-badge [severity]="searchStatusSeverity(event.searchStatus)" size="sm">
{{ event.searchStatus }}
@@ -155,9 +154,14 @@
@if (event.cycleId) {
<span class="list-row__cycle">{{ event.cycleId.substring(0, 8) }}</span>
}
<span class="list-row__meta">{{ event.instanceName }}</span>
@if (event.instanceType) {
<app-badge [severity]="instanceTypeSeverity(event.instanceType)" size="sm" class="list-row__instance">{{ event.instanceType }}</app-badge>
}
<span class="list-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
<div class="list-row__title">
{{ event.itemTitle || 'Search triggered' }}
</div>
@if (event.grabbedItems && event.grabbedItems.length > 0) {
<div class="list-row__detail">
<ng-icon name="tablerDownload" class="list-row__detail-icon" />

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -35,7 +35,6 @@
<div class="upgrade-row">
<div class="upgrade-row__main">
<ng-icon name="tablerTrendingUp" class="upgrade-row__icon" />
<span class="upgrade-row__title">{{ upgrade.title }}</span>
<div class="upgrade-row__scores">
<span class="upgrade-row__score upgrade-row__score--old">{{ upgrade.previousScore }}</span>
<ng-icon name="tablerArrowRight" class="upgrade-row__arrow" />
@@ -47,6 +46,9 @@
</app-badge>
<span class="upgrade-row__time">{{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
<div class="upgrade-row__title">
{{ upgrade.title }}
</div>
</div>
} @empty {
<app-empty-state

View File

@@ -101,8 +101,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;
}
@@ -159,13 +158,7 @@
padding: var(--space-2) var(--space-3);
}
.upgrade-row__scores {
flex-basis: 100%;
order: 3;
}
.upgrade-row__time {
order: 4;
flex-basis: 100%;
.upgrade-row__title {
padding: 0 var(--space-3) var(--space-2);
}
}