mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-08 23:03:13 -04:00
Add search event reason (#546)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Data.Models.Arr;
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public class SearchItem
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
|
||||
12
code/backend/Cleanuparr.Domain/Enums/SeekerSearchReason.cs
Normal file
12
code/backend/Cleanuparr.Domain/Enums/SeekerSearchReason.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SeekerSearchReason
|
||||
{
|
||||
Missing,
|
||||
QualityCutoffNotMet,
|
||||
CustomFormatScoreBelowCutoff,
|
||||
Replacement,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// }
|
||||
// }
|
||||
@@ -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();
|
||||
// }
|
||||
// }
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -47,6 +47,7 @@ public partial class QBitService
|
||||
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<>) &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#nullable disable
|
||||
|
||||
using Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
454
code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.Designer.cs
generated
Normal file
454
code/backend/Cleanuparr.Persistence/Migrations/Events/20260405174732_AddSearchEventData.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user