From def4e8afdafd1c5f99da731d765840fcd393fa52 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 20 Mar 2026 00:34:00 +0200 Subject: [PATCH] fixed dry run usage --- .../Responses/SearchEventResponse.cs | 1 + .../Controllers/SearchStatsController.cs | 1 + .../DownloadRemover/QueueItemRemoverTests.cs | 1 - .../Events/EventPublisher.cs | 3 +- .../Features/Jobs/Seeker.cs | 107 +++++++++++------- .../app/core/models/search-stats.models.ts | 1 + .../search-stats/search-stats.component.html | 3 + 7 files changed, 75 insertions(+), 42 deletions(-) diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs index 4492250c..2743200b 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchEventResponse.cs @@ -15,4 +15,5 @@ public sealed record SearchEventResponse public DateTime? CompletedAt { get; init; } public object? GrabbedItems { get; init; } public Guid? CycleRunId { get; init; } + public bool IsDryRun { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index 3d8eb7c5..ebf5686b 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -257,6 +257,7 @@ public sealed class SearchStatsController : ControllerBase CompletedAt = e.CompletedAt, GrabbedItems = parsed.GrabbedItems, CycleRunId = e.CycleRunId, + IsDryRun = e.IsDryRun, }; }).ToList(); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index 4e6d4265..8744a7b9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -4,7 +4,6 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Infrastructure.Features.DownloadHunter.Models; using Cleanuparr.Infrastructure.Features.DownloadRemover; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.ItemStriker; diff --git a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs index 06a98bca..c17d4290 100644 --- a/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Events/EventPublisher.cs @@ -253,7 +253,8 @@ public class EventPublisher : IEventPublisher CycleRunId = cycleRunId, }; - await _dryRunInterceptor.InterceptAsync(SaveEventToDatabase, eventEntity); + eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + await SaveEventToDatabase(eventEntity); await NotifyClientsAsync(eventEntity); await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 3364a0f4..91dd3ac9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -55,6 +55,8 @@ public sealed class Seeker : IHandler return; } + bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled(); + // Replacement searches queued after download removal SearchQueueItem? replacementItem = await _dataContext.SearchQueue .OrderBy(q => q.CreatedAt) @@ -62,7 +64,7 @@ public sealed class Seeker : IHandler if (replacementItem is not null) { - await ProcessReplacementItemAsync(replacementItem); + await ProcessReplacementItemAsync(replacementItem, isDryRun); return; } @@ -72,10 +74,10 @@ public sealed class Seeker : IHandler return; } - await ProcessProactiveSearchAsync(config); + await ProcessProactiveSearchAsync(config, isDryRun); } - private async Task ProcessReplacementItemAsync(SearchQueueItem item) + private async Task ProcessReplacementItemAsync(SearchQueueItem item, bool isDryRun) { ArrInstance? arrInstance = await _dataContext.ArrInstances .Include(a => a.ArrConfig) @@ -103,7 +105,10 @@ public sealed class Seeker : IHandler Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, 1, [item.Title], SeekerSearchType.Replacement); - await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, item.ArrInstance.ArrConfig.Type, item.ItemId, item.Title); + if (!isDryRun) + { + await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, item.ArrInstance.ArrConfig.Type, item.ItemId, item.Title); + } _logger.LogInformation("Replacement search triggered for '{Title}' on {InstanceName}", item.Title, arrInstance.Name); @@ -115,8 +120,11 @@ public sealed class Seeker : IHandler } finally { - _dataContext.SearchQueue.Remove(item); - await _dataContext.SaveChangesAsync(); + if (!isDryRun) + { + _dataContext.SearchQueue.Remove(item); + await _dataContext.SaveChangesAsync(); + } } } @@ -138,7 +146,7 @@ public sealed class Seeker : IHandler return [new SearchItem { Id = item.ItemId }]; } - private async Task ProcessProactiveSearchAsync(SeekerConfig config) + private async Task ProcessProactiveSearchAsync(SeekerConfig config, bool isDryRun) { List instanceConfigs = await _dataContext.SeekerInstanceConfigs .Include(s => s.ArrInstance) @@ -163,19 +171,19 @@ public sealed class Seeker : IHandler .OrderBy(s => s.LastProcessedAt ?? DateTime.MinValue) .First(); - await ProcessSingleInstanceAsync(config, nextInstance); + await ProcessSingleInstanceAsync(config, nextInstance, isDryRun); } else { // Process all enabled instances sequentially foreach (SeekerInstanceConfig instanceConfig in instanceConfigs) { - await ProcessSingleInstanceAsync(config, instanceConfig); + await ProcessSingleInstanceAsync(config, instanceConfig, isDryRun); } } } - private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig) + private async Task ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun) { ArrInstance arrInstance = instanceConfig.ArrInstance; InstanceType instanceType = arrInstance.ArrConfig.Type; @@ -189,7 +197,7 @@ public sealed class Seeker : IHandler try { - await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType); + await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun); } catch (Exception ex) { @@ -197,17 +205,21 @@ public sealed class Seeker : IHandler instanceType, arrInstance.Name); } - // Always update LastProcessedAt so round-robin moves on - instanceConfig.LastProcessedAt = DateTime.UtcNow; - _dataContext.SeekerInstanceConfigs.Update(instanceConfig); - await _dataContext.SaveChangesAsync(); + if (!isDryRun) + { + // Update LastProcessedAt so round-robin moves on + instanceConfig.LastProcessedAt = DateTime.UtcNow; + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + } } private async Task ProcessInstanceAsync( SeekerConfig config, SeekerInstanceConfig instanceConfig, ArrInstance arrInstance, - InstanceType instanceType) + InstanceType instanceType, + bool isDryRun) { // Load search history for the current cycle List currentCycleHistory = await _dataContext.SeekerHistory @@ -235,14 +247,14 @@ public sealed class Seeker : IHandler if (instanceType == InstanceType.Radarr) { List selectedIds; - (selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory); + (selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun); searchItems = selectedIds.Select(id => new SearchItem { Id = id }).ToHashSet(); historyIds = selectedIds; } else { (searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) = - await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory); + await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun); } IEnumerable historyExternalIds = allHistory.Select(h => h.ExternalItemId); @@ -250,37 +262,45 @@ public sealed class Seeker : IHandler if (searchItems.Count == 0) { _logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name); - await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, historyExternalIds); + if (!isDryRun) + { + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, historyExternalIds); + } return; } - // Trigger search + // Trigger search (arr client guards the HTTP request via dry run interceptor) IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version); List commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems); - // Update search history - await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentRunId, historyIds, selectedNames, seasonNumber); - - // Publish event and track commands + // Publish event (always saved, flagged with IsDryRun in EventPublisher) Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, searchItems.Count, selectedNames, SeekerSearchType.Proactive, instanceConfig.CurrentRunId); - long externalItemId = historyIds.FirstOrDefault(); - string itemTitle = selectedNames.FirstOrDefault() ?? string.Empty; - await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType, externalItemId, itemTitle, seasonNumber); - _logger.LogInformation("Searched {Count} items on {InstanceName}: {Items}", searchItems.Count, arrInstance.Name, string.Join(", ", selectedNames)); - // Cleanup stale history entries and old cycle history - await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, historyExternalIds); - await CleanupOldCycleHistoryAsync(arrInstance.Id, instanceConfig.CurrentRunId); + if (!isDryRun) + { + // Update search history + await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentRunId, historyIds, selectedNames, seasonNumber); + + // 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, historyExternalIds); + await CleanupOldCycleHistoryAsync(arrInstance.Id, instanceConfig.CurrentRunId); + } } private async Task<(List SelectedIds, List SelectedNames, List AllLibraryIds)> ProcessRadarrAsync( SeekerConfig config, ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, - Dictionary searchHistory) + Dictionary searchHistory, + bool isDryRun) { List movies = await _radarrClient.GetAllMoviesAsync(arrInstance); List allLibraryIds = movies.Select(m => m.Id).ToList(); @@ -320,9 +340,12 @@ public sealed class Seeker : IHandler { _logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle", candidates.Count, arrInstance.Name); - instanceConfig.CurrentRunId = Guid.NewGuid(); - _dataContext.SeekerInstanceConfigs.Update(instanceConfig); - await _dataContext.SaveChangesAsync(); + if (!isDryRun) + { + instanceConfig.CurrentRunId = Guid.NewGuid(); + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + } searchHistory = new Dictionary(); } @@ -348,7 +371,8 @@ public sealed class Seeker : IHandler ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, Dictionary seriesSearchHistory, - List currentCycleHistory) + List currentCycleHistory, + bool isDryRun) { List series = await _sonarrClient.GetAllSeriesAsync(arrInstance); List allLibraryIds = series.Select(s => s.Id).ToList(); @@ -415,13 +439,16 @@ public sealed class Seeker : IHandler { _logger.LogInformation("All series/seasons on {InstanceName} searched in current cycle, starting new cycle", arrInstance.Name); - instanceConfig.CurrentRunId = Guid.NewGuid(); - _dataContext.SeekerInstanceConfigs.Update(instanceConfig); - await _dataContext.SaveChangesAsync(); + if (!isDryRun) + { + instanceConfig.CurrentRunId = Guid.NewGuid(); + _dataContext.SeekerInstanceConfigs.Update(instanceConfig); + await _dataContext.SaveChangesAsync(); + } // Retry with fresh cycle return await ProcessSonarrAsync(config, arrInstance, instanceConfig, - new Dictionary(), []); + new Dictionary(), [], isDryRun); } return ([], [], allLibraryIds, [], 0); diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index 7d58535f..9991148f 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -51,4 +51,5 @@ export interface SearchEvent { completedAt: string | null; grabbedItems: unknown[] | null; cycleRunId: string | null; + isDryRun: boolean; } diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.html b/code/frontend/src/app/features/search-stats/search-stats.component.html index 8d01b78a..8d354c86 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.html +++ b/code/frontend/src/app/features/search-stats/search-stats.component.html @@ -126,6 +126,9 @@ {{ event.searchStatus }} } + @if (event.isDryRun) { + Dry Run + } @if (event.cycleRunId) { {{ event.cycleRunId.substring(0, 8) }} }