added seeker cycle completed notification event

This commit is contained in:
Flaminel committed 2026-10-21 01:14:27 +03:00
1 parent c5e6865144
commit 12fa674246
22 files changed
+4547 -6

No files matched your search

@@ -152,7 +152,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = request.OnSearchTriggered,
OnSearchItemGrabbed = request.OnSearchItemGrabbed,
OnForceImported = request.OnForceImported,
OnErroredStrike = request.OnErroredStrike
OnErroredStrike = request.OnErroredStrike,
OnSeekerCycleCompleted = request.OnSeekerCycleCompleted
}, config);
await _dataContext.NotificationConfigs.AddAsync(provider);
@@ -229,6 +230,7 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchItemGrabbed = request.OnSearchItemGrabbed,
OnForceImported = request.OnForceImported,
OnErroredStrike = request.OnErroredStrike,
OnSeekerCycleCompleted = request.OnSeekerCycleCompleted,
UpdatedAt = _timeProvider.GetUtcNow()
}, newConfig);
@@ -331,7 +333,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = false,
OnSearchItemGrabbed = false,
OnForceImported = false,
OnErroredStrike = false
OnErroredStrike = false,
OnSeekerCycleCompleted = false
},
Configuration = config
};
@@ -367,7 +370,8 @@ public sealed class NotificationProvidersController : ControllerBase
OnSearchTriggered = provider.OnSearchTriggered,
OnSearchItemGrabbed = provider.OnSearchItemGrabbed,
OnForceImported = provider.OnForceImported,
OnErroredStrike = provider.OnErroredStrike
OnErroredStrike = provider.OnErroredStrike,
OnSeekerCycleCompleted = provider.OnSeekerCycleCompleted
},
Configuration = (object?)descriptor.GetConfig(provider) ?? new object()
};
@@ -17,6 +17,7 @@ public enum EventType
ForceImported,
DownloadStopped,
ErroredStrike,
SeekerCycleCompleted,
/// <summary>
/// Text this build does not know.
@@ -14,5 +14,6 @@ public enum NotificationEventType
SearchItemGrabbed,
DownloadStopped,
ForceImported,
ErroredStrike
ErroredStrike,
SeekerCycleCompleted
}
@@ -1115,4 +1115,102 @@ public class EventPublisherTests : IDisposable
}
#endregion
#region PublishSeekerCycleCompleted Tests
private async Task SeedSearchTriggered(Guid cycleId, SeekerSearchReason reason, bool grabbed)
{
_context.Events.Add(new AppEvent
{
EventType = EventType.SearchTriggered,
Message = "Search triggered",
Severity = EventSeverity.Information,
CycleId = cycleId,
SearchReason = reason,
GrabbedItems = grabbed ? ["Release A"] : []
});
await _context.SaveChangesAsync();
}
private static SeekerCycle CreateCompletedCycle(bool isDryRun = false)
{
DateTimeOffset startedAt = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
return new SeekerCycle
{
StartedAt = startedAt,
CompletedAt = startedAt.AddHours(5).AddMinutes(30),
SearchedUnits = 120,
GrabbedUnits = 14,
IsDryRun = isDryRun
};
}
[Fact]
public async Task PublishSeekerCycleCompleted_SavesEventAndNotifies()
{
// Arrange
SeekerCycle cycle = CreateCompletedCycle();
// Act
await _publisher.PublishSeekerCycleCompleted(cycle, "Radarr Main");
// Assert
AppEvent? savedEvent = await _context.Events.SingleOrDefaultAsync(e => e.EventType == EventType.SeekerCycleCompleted);
savedEvent.ShouldNotBeNull();
savedEvent.CycleId.ShouldBe(cycle.Id);
await _notificationPublisher.Received(1).NotifySeekerCycleCompleted(
"Radarr Main", TimeSpan.FromHours(5.5), 120, 14, 0);
}
[Fact]
public async Task PublishSeekerCycleCompleted_CalledTwiceForSameCycle_PublishesOnce()
{
// Arrange
SeekerCycle cycle = CreateCompletedCycle();
// Act
await _publisher.PublishSeekerCycleCompleted(cycle, "Radarr Main");
await _publisher.PublishSeekerCycleCompleted(cycle, "Radarr Main");
// Assert
(await _context.Events.CountAsync(e => e.EventType == EventType.SeekerCycleCompleted)).ShouldBe(1);
await _notificationPublisher.Received(1).NotifySeekerCycleCompleted(
Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task PublishSeekerCycleCompleted_CountsOnlyGrabbedUpgradeSearchesOfThisCycle()
{
// Arrange
SeekerCycle cycle = CreateCompletedCycle();
await SeedSearchTriggered(cycle.Id, SeekerSearchReason.QualityCutoffNotMet, grabbed: true);
await SeedSearchTriggered(cycle.Id, SeekerSearchReason.CustomFormatScoreBelowCutoff, grabbed: true);
await SeedSearchTriggered(cycle.Id, SeekerSearchReason.Missing, grabbed: true);
await SeedSearchTriggered(cycle.Id, SeekerSearchReason.QualityCutoffNotMet, grabbed: false);
await SeedSearchTriggered(Guid.NewGuid(), SeekerSearchReason.QualityCutoffNotMet, grabbed: true);
// Act
await _publisher.PublishSeekerCycleCompleted(cycle, "Radarr Main");
// Assert
await _notificationPublisher.Received(1).NotifySeekerCycleCompleted(
"Radarr Main", Arg.Any<TimeSpan>(), 120, 14, 2);
}
[Fact]
public async Task PublishSeekerCycleCompleted_ForDryRunCycle_SavesDryRunEvent()
{
// Arrange
SeekerCycle cycle = CreateCompletedCycle(isDryRun: true);
// Act
await _publisher.PublishSeekerCycleCompleted(cycle, "Radarr Main");
// Assert
AppEvent? savedEvent = await _context.Events.SingleOrDefaultAsync(e => e.EventType == EventType.SeekerCycleCompleted);
savedEvent.ShouldNotBeNull();
savedEvent.IsDryRun.ShouldBeTrue();
}
#endregion
}
@@ -7963,6 +7963,8 @@ public class SeekerTests : IDisposable
// Assert: the empty cycle stays open
await mockArrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
await _fixture.EventPublisher.DidNotReceive()
.PublishSeekerCycleCompleted(Arg.Any<SeekerCycle>(), Arg.Any<string>());
var cycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == currentCycleId);
cycle.CompletedAt.ShouldBeNull();
@@ -8455,6 +8457,77 @@ public class SeekerTests : IDisposable
cycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed);
}
[Fact]
public async Task ExecuteAsync_Radarr_CycleCompletes_PublishesCycleCompletedOnlyOnce()
{
// Arrange: the cycle searched 2 movies and the only remaining candidate is in the queue
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
Guid currentCycleId = Guid.NewGuid();
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7
});
_fixture.EventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = currentCycleId,
ArrInstanceId = radarrInstance.Id,
StartedAt = _fixture.TimeProvider.GetUtcNow(),
SearchedUnits = 2,
IsDryRun = false
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
QueueRecord[] queuedRecords =
[
new() { Id = 1, Title = "Movie 1 Download", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 1, TrackedDownloadState = "downloading" }
];
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns(queuedRecords.ToList());
List<SearchableMovie> movies =
[
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
];
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable(movies));
StubRadarrWanted(radarrInstance, movies);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
// Act 1: the cycle completes
await CreateSut().ExecuteAsync();
// Assert 1
await _fixture.EventPublisher.Received(1)
.PublishSeekerCycleCompleted(Arg.Is<SeekerCycle>(c => c.Id == currentCycleId), Arg.Any<string>());
// Act 2: a later run while the instance waits for MinCycleTimeDays
await CreateSut().ExecuteAsync();
// Assert 2
await _fixture.EventPublisher.Received(1)
.PublishSeekerCycleCompleted(Arg.Any<SeekerCycle>(), Arg.Any<string>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_AllSeasonsQueued_CompletesCycleWithPriorSearches()
{
@@ -180,6 +180,7 @@ public class NotificationConfigurationServiceTests : IDisposable
[InlineData(NotificationEventType.SearchItemGrabbed)]
[InlineData(NotificationEventType.ForceImported)]
[InlineData(NotificationEventType.ErroredStrike)]
[InlineData(NotificationEventType.SeekerCycleCompleted)]
public async Task GetProvidersForEventAsync_ReturnsProviderForCorrectEvents(NotificationEventType eventType)
{
// Arrange
@@ -194,6 +195,7 @@ public class NotificationConfigurationServiceTests : IDisposable
bool onSearchItemGrabbed = eventType == NotificationEventType.SearchItemGrabbed;
bool onForceImported = eventType == NotificationEventType.ForceImported;
bool onErroredStrike = eventType == NotificationEventType.ErroredStrike;
bool onSeekerCycleCompleted = eventType == NotificationEventType.SeekerCycleCompleted;
var config = new NotificationConfig
{
@@ -212,6 +214,7 @@ public class NotificationConfigurationServiceTests : IDisposable
OnSearchItemGrabbed = onSearchItemGrabbed,
OnForceImported = onForceImported,
OnErroredStrike = onErroredStrike,
OnSeekerCycleCompleted = onSeekerCycleCompleted,
NotifiarrConfiguration = new NotifiarrConfig
{
Id = Guid.NewGuid(),
@@ -505,6 +505,62 @@ public class NotificationPublisherTests
#endregion
#region NotifySeekerCycleCompleted Tests
[Fact]
public async Task NotifySeekerCycleCompleted_PublishesNotificationWithCorrectContext()
{
// Arrange
SetupContext(InstanceType.Radarr);
// Act
await _publisher.NotifySeekerCycleCompleted("Radarr Main", TimeSpan.FromHours(5) + TimeSpan.FromMinutes(30), 120, 14, 6);
// Assert
NotificationMessage m = _notificationQueue.ShouldHaveSingle();
m.EventType.ShouldBe(NotificationEventType.SeekerCycleCompleted);
m.Context.EventType.ShouldBe(NotificationEventType.SeekerCycleCompleted);
m.Context.Severity.ShouldBe(EventSeverity.Information);
m.Context.Data["Instance"].ShouldBe("Radarr Main");
m.Context.Data["Instance type"].ShouldBe("Radarr");
m.Context.Data["Url"].ShouldBe("http://sonarr.local/");
m.Context.Data["Duration"].ShouldBe("5h 30m");
m.Context.Data["Searched"].ShouldBe("120");
m.Context.Data["Grabbed"].ShouldBe("14");
m.Context.Data["Upgrades grabbed"].ShouldBe("6");
}
[Fact]
public async Task NotifySeekerCycleCompleted_WithMultiDayDuration_FormatsDaysAndHours()
{
// Arrange
SetupContext();
// Act
await _publisher.NotifySeekerCycleCompleted("Sonarr", TimeSpan.FromDays(3) + TimeSpan.FromHours(4) + TimeSpan.FromMinutes(20), 1, 1, 0);
// Assert
NotificationMessage m = _notificationQueue.ShouldHaveSingle();
m.Context.Data["Duration"].ShouldBe("3d 4h");
}
[Fact]
public async Task NotifySeekerCycleCompleted_WhenExceptionOccurs_LogsError()
{
// Arrange
SetupContext();
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ThrowsAsync(new Exception("Error"));
// Act
await _publisher.NotifySeekerCycleCompleted("Sonarr", TimeSpan.FromHours(1), 1, 1, 0);
// Assert
_logger.HasLogContaining(LogLevel.Error, "Failed to notify seeker cycle completed").ShouldBeTrue();
}
#endregion
#region NotifyForceImported Tests
[Fact]
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Persistence.Providers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -426,6 +427,36 @@ public class EventPublisher : IEventPublisher
}
}
public async Task PublishSeekerCycleCompleted(SeekerCycle cycle, string instanceName)
{
bool alreadyPublished = await _context.Events
.AnyAsync(e => e.EventType == EventType.SeekerCycleCompleted && e.CycleId == cycle.Id);
if (alreadyPublished)
{
return;
}
int upgradesGrabbed = await _context.Events
.Where(e => e.CycleId == cycle.Id && e.EventType == EventType.SearchTriggered)
.Where(e => e.SearchReason == SeekerSearchReason.QualityCutoffNotMet
|| e.SearchReason == SeekerSearchReason.CustomFormatScoreBelowCutoff)
.Where(e => e.GrabbedItems.Count > 0)
.CountAsync();
TimeSpan duration = (cycle.CompletedAt ?? _timeProvider.GetUtcNow()) - cycle.StartedAt;
await PublishAsync(
EventType.SeekerCycleCompleted,
$"Cycle completed on {instanceName} after {cycle.SearchedUnits} searches, {cycle.GrabbedUnits} grabbed",
EventSeverity.Information,
configure: e => e.CycleId = cycle.Id,
isDryRun: cycle.IsDryRun);
await _notificationPublisher.NotifySeekerCycleCompleted(
instanceName, duration, cycle.SearchedUnits, cycle.GrabbedUnits, upgradesGrabbed);
}
/// <summary>
/// Updates an existing search event with the started status
/// </summary>
@@ -1,5 +1,6 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
namespace Cleanuparr.Infrastructure.Events.Interfaces;
@@ -76,6 +77,11 @@ public interface IEventPublisher
/// </summary>
Task PublishSearchStarted(Guid eventId);
/// <summary>
/// Records the first completion of a Seeker cycle and notifies about it. Later completions of the same cycle are ignored.
/// </summary>
Task PublishSeekerCycleCompleted(SeekerCycle cycle, string instanceName);
/// <summary>
/// Fails each search event of an arr instance that has no terminal status.
/// </summary>
@@ -1406,6 +1406,8 @@ public sealed class Seeker : IHandler
_logger.LogInformation("Cycle completed on {InstanceName} after {Count} searches",
instanceName, cycle.SearchedUnits);
await _eventPublisher.PublishSeekerCycleCompleted(cycle, instanceName);
}
if (ShouldWaitForMinCycleTime(instanceConfig, cycle.StartedAt))
@@ -22,4 +22,9 @@ public interface INotificationPublisher
Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason);
Task NotifySearchItemGrabbed(string itemTitle, List<string> grabbedItems, InstanceType instanceType, string instanceUrl);
/// <summary>
/// Reports that Seeker searched every eligible item of an arr instance once.
/// </summary>
Task NotifySeekerCycleCompleted(string instanceName, TimeSpan duration, int searchedUnits, int grabbedUnits, int upgradesGrabbed);
}
@@ -23,4 +23,6 @@ public record NotificationEventFlags
public bool OnForceImported { get; init; }
public bool OnErroredStrike { get; init; }
public bool OnSeekerCycleCompleted { get; init; }
}
@@ -144,7 +144,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
OnSearchTriggered = config.OnSearchTriggered,
OnSearchItemGrabbed = config.OnSearchItemGrabbed,
OnForceImported = config.OnForceImported,
OnErroredStrike = config.OnErroredStrike
OnErroredStrike = config.OnErroredStrike,
OnSeekerCycleCompleted = config.OnSeekerCycleCompleted
};
var configuration = config.Type switch
@@ -185,6 +186,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationEventType.SearchItemGrabbed => events.OnSearchItemGrabbed,
NotificationEventType.ForceImported => events.OnForceImported,
NotificationEventType.ErroredStrike => events.OnErroredStrike,
NotificationEventType.SeekerCycleCompleted => events.OnSeekerCycleCompleted,
NotificationEventType.Test => true,
_ => throw new ArgumentOutOfRangeException(nameof(eventType), $"Provider type {eventType} is not yet registered")
};
@@ -139,6 +139,19 @@ public class NotificationPublisher : INotificationPublisher
}
}
public virtual async Task NotifySeekerCycleCompleted(string instanceName, TimeSpan duration, int searchedUnits, int grabbedUnits, int upgradesGrabbed)
{
try
{
NotificationContext context = BuildSeekerCycleCompletedContext(instanceName, duration, searchedUnits, grabbedUnits, upgradesGrabbed);
await SendNotificationAsync(NotificationEventType.SeekerCycleCompleted, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to notify seeker cycle completed");
}
}
private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
{
await _dryRunInterceptor.InterceptAsync(() => SendNotificationInternalAsync((eventType, context)));
@@ -358,6 +371,40 @@ public class NotificationPublisher : INotificationPublisher
};
}
private static NotificationContext BuildSeekerCycleCompletedContext(string instanceName, TimeSpan duration, int searchedUnits, int grabbedUnits, int upgradesGrabbed)
{
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
Uri instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
return new NotificationContext
{
EventType = NotificationEventType.SeekerCycleCompleted,
Title = "Seeker cycle completed",
Description = $"Searched every eligible item on {instanceName} once",
Severity = EventSeverity.Information,
Data = new Dictionary<string, string>
{
["Instance"] = instanceName,
["Instance type"] = instanceType.ToString(),
["Url"] = instanceUrl.ToString(),
["Duration"] = FormatDuration(duration),
["Searched"] = searchedUnits.ToString(CultureInfo.InvariantCulture),
["Grabbed"] = grabbedUnits.ToString(CultureInfo.InvariantCulture),
["Upgrades grabbed"] = upgradesGrabbed.ToString(CultureInfo.InvariantCulture),
}
};
}
private static string FormatDuration(TimeSpan duration)
{
if (duration.TotalDays >= 1)
{
return $"{(int)duration.TotalDays}d {duration.Hours}h";
}
return $"{duration.Hours}h {duration.Minutes}m";
}
private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType)
{
return strikeType switch
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerCycleCompletedNotification : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "on_seeker_cycle_completed",
schema: "data",
table: "notification_configs",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "on_seeker_cycle_completed",
schema: "data",
table: "notification_configs");
}
}
}
@@ -1217,6 +1217,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data
.HasColumnType("boolean")
.HasColumnName("on_search_triggered");
b.Property<bool>("OnSeekerCycleCompleted")
.HasColumnType("boolean")
.HasColumnName("on_seeker_cycle_completed");
b.Property<bool>("OnSlowStrike")
.HasColumnType("boolean")
.HasColumnName("on_slow_strike");
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerCycleCompletedNotification : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "on_seeker_cycle_completed",
table: "notification_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "on_seeker_cycle_completed",
table: "notification_configs");
}
}
}
@@ -1212,6 +1212,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("on_search_triggered");
b.Property<bool>("OnSeekerCycleCompleted")
.HasColumnType("INTEGER")
.HasColumnName("on_seeker_cycle_completed");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
@@ -41,6 +41,8 @@ public sealed record NotificationConfig
public bool OnErroredStrike { get; init; }
public bool OnSeekerCycleCompleted { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; init; } = DateTimeOffset.UtcNow;
@@ -84,5 +86,6 @@ public sealed record NotificationConfig
OnSearchTriggered ||
OnSearchItemGrabbed ||
OnForceImported ||
OnErroredStrike;
OnErroredStrike ||
OnSeekerCycleCompleted;
}
+1
View File
@@ -378,6 +378,7 @@ NotificationConfig MakeNotificationConfig(string name, NotificationProviderType
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnSearchItemGrabbed = rng.Next(2) == 0,
OnSeekerCycleCompleted = rng.Next(2) == 0,
OnCategoryChanged = rng.Next(2) == 0,
};