diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
index d486fd8a..5cbf4320 100644
--- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
+++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs
@@ -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()
};
diff --git a/code/backend/Cleanuparr.Domain/Enums/EventType.cs b/code/backend/Cleanuparr.Domain/Enums/EventType.cs
index 996b5674..ecc4ceeb 100644
--- a/code/backend/Cleanuparr.Domain/Enums/EventType.cs
+++ b/code/backend/Cleanuparr.Domain/Enums/EventType.cs
@@ -17,6 +17,7 @@ public enum EventType
ForceImported,
DownloadStopped,
ErroredStrike,
+ SeekerCycleCompleted,
///
/// Text this build does not know.
diff --git a/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs b/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs
index 4d2440e9..35dc3523 100644
--- a/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs
+++ b/code/backend/Cleanuparr.Domain/Enums/NotificationEventType.cs
@@ -14,5 +14,6 @@ public enum NotificationEventType
SearchItemGrabbed,
DownloadStopped,
ForceImported,
- ErroredStrike
+ ErroredStrike,
+ SeekerCycleCompleted
}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs
index 39331574..784e4242 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Events/EventPublisherTests.cs
@@ -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(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ }
+
+ [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(), 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
}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
index 89b8614e..0376af4c 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs
@@ -7963,6 +7963,8 @@ public class SeekerTests : IDisposable
// Assert: the empty cycle stays open
await mockArrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any());
+ await _fixture.EventPublisher.DidNotReceive()
+ .PublishSeekerCycleCompleted(Arg.Any(), Arg.Any());
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();
+
+ 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())
+ .Returns(queuedRecords.ToList());
+
+ List movies =
+ [
+ new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] }
+ ];
+ _radarrClient
+ .StreamAllMoviesAsync(radarrInstance, Arg.Any())
+ .Returns(ToAsyncEnumerable(movies));
+ StubRadarrWanted(radarrInstance, movies);
+
+ _fixture.ArrClientFactory
+ .GetClient(InstanceType.Radarr, Arg.Any())
+ .Returns(mockArrClient);
+
+ // Act 1: the cycle completes
+ await CreateSut().ExecuteAsync();
+
+ // Assert 1
+ await _fixture.EventPublisher.Received(1)
+ .PublishSeekerCycleCompleted(Arg.Is(c => c.Id == currentCycleId), Arg.Any());
+
+ // Act 2: a later run while the instance waits for MinCycleTimeDays
+ await CreateSut().ExecuteAsync();
+
+ // Assert 2
+ await _fixture.EventPublisher.Received(1)
+ .PublishSeekerCycleCompleted(Arg.Any(), Arg.Any());
+ }
+
[Fact]
public async Task ExecuteAsync_Sonarr_AllSeasonsQueued_CompletesCycleWithPriorSearches()
{
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs
index c92cd69c..17aae260 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs
@@ -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(),
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs
index 963251cc..a58dfdb4 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs
@@ -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>(), Arg.Any())
+ .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]
diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs
index de20bcb8..6c5c64b9 100644
--- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs
@@ -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);
+ }
+
///
/// Updates an existing search event with the started status
///
diff --git a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs
index 4115b802..45d307c1 100644
--- a/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Events/Interfaces/IEventPublisher.cs
@@ -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
///
Task PublishSearchStarted(Guid eventId);
+ ///
+ /// Records the first completion of a Seeker cycle and notifies about it. Later completions of the same cycle are ignored.
+ ///
+ Task PublishSeekerCycleCompleted(SeekerCycle cycle, string instanceName);
+
///
/// Fails each search event of an arr instance that has no terminal status.
///
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
index 29bb784b..29cdad83 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
@@ -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))
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs
index 0551f9f6..cfed6e0e 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/INotificationPublisher.cs
@@ -22,4 +22,9 @@ public interface INotificationPublisher
Task NotifySearchTriggered(string itemTitle, SeekerSearchType searchType, SeekerSearchReason searchReason);
Task NotifySearchItemGrabbed(string itemTitle, List grabbedItems, InstanceType instanceType, string instanceUrl);
+
+ ///
+ /// Reports that Seeker searched every eligible item of an arr instance once.
+ ///
+ Task NotifySeekerCycleCompleted(string instanceName, TimeSpan duration, int searchedUnits, int grabbedUnits, int upgradesGrabbed);
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs
index a14e2ed7..6a08d432 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationEventFlags.cs
@@ -23,4 +23,6 @@ public record NotificationEventFlags
public bool OnForceImported { get; init; }
public bool OnErroredStrike { get; init; }
+
+ public bool OnSeekerCycleCompleted { get; init; }
}
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs
index 3565c464..2d1fd5bd 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs
@@ -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")
};
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs
index abf1cc87..23ec1667 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs
@@ -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