diff --git a/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs b/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs new file mode 100644 index 00000000..83b543c7 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SeekerCycleCompletionReason.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeekerCycleCompletionReason +{ + Completed, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SeekerSearchOutcome.cs b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchOutcome.cs new file mode 100644 index 00000000..d88c7dbd --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SeekerSearchOutcome.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeekerSearchOutcome +{ + Grabbed, + NothingGrabbed, + Failed, +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 14bfd3b0..a8ffb32f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -6913,4 +6913,777 @@ public class SeekerTests : IDisposable } #endregion + + #region SeekerCycle Persistence + + [Fact] + public async Task ExecuteAsync_Radarr_NoCycleRow_CreatesOneStartingAtEarliestHistory() + { + // Arrange: the config points at a cycle with existing history but no SeekerCycle row yet + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + var earliest = now.AddDays(-10); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = earliest, + ItemTitle = "Movie 1" + }); + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 2, + ItemType = InstanceType.Radarr, + CycleId = currentCycleId, + LastSearchedAt = now.AddDays(-5), + ItemTitle = "Movie 2" + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: a cycle row now exists, starting at the earliest history row, with both pre-existing searches plus the one just triggered + var cycle = await _fixture.EventsContext.SeekerCycles + .FirstOrDefaultAsync(c => c.ArrInstanceId == radarrInstance.Id && !c.IsDryRun); + cycle.ShouldNotBeNull(); + cycle.Id.ShouldBe(currentCycleId); + cycle.StartedAt.ShouldBe(earliest); + cycle.SearchedUnits.ShouldBe(3); + } + + [Fact] + public async Task ExecuteAsync_Radarr_NoCycleRowAndNoHistory_CreatesOneStartingNow() + { + // Arrange: the config points at a cycle with no SeekerCycle row and no history at all + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + await _fixture.DataContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the new cycle row starts now and counts only the search just triggered + var cycle = await _fixture.EventsContext.SeekerCycles + .FirstOrDefaultAsync(c => c.ArrInstanceId == radarrInstance.Id && !c.IsDryRun); + cycle.ShouldNotBeNull(); + cycle.Id.ShouldBe(currentCycleId); + cycle.StartedAt.ShouldBe(now); + cycle.SearchedUnits.ShouldBe(1); + } + + [Fact] + public async Task ExecuteAsync_Radarr_StaleOpenCycle_CompletesFromItsNewestEvent() + { + // Arrange: a stale open cycle (not the config's current one) has a search event; it should be closed using that event's timestamp + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var staleCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + var staleStartedAt = now.AddDays(-20); + var staleEventAt = now.AddDays(-15); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = staleCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = staleStartedAt, + IsDryRun = false + }); + _fixture.EventsContext.Events.Add(new Cleanuparr.Persistence.Models.Events.AppEvent + { + EventType = EventType.SearchTriggered, + Message = "stale cycle search", + Severity = EventSeverity.Information, + CycleId = staleCycleId, + Timestamp = staleEventAt + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the stale cycle closed using its newest event's timestamp + var staleCycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == staleCycleId); + staleCycle.CompletedAt.ShouldBe(staleEventAt); + staleCycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed); + } + + [Fact] + public async Task ExecuteAsync_Radarr_StaleOpenCycleWithNoEvents_CompletesUsingItsStartedAt() + { + // Arrange: a stale open cycle has no search events at all; it should close using its own StartedAt + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + var staleCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + var staleStartedAt = now.AddDays(-20); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = staleCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = staleStartedAt, + IsDryRun = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the stale cycle closed using its own StartedAt, having no events to read from + var staleCycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == staleCycleId); + staleCycle.CompletedAt.ShouldBe(staleStartedAt); + staleCycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed); + } + + [Fact] + public async Task ExecuteAsync_Radarr_Search_UpdatesEligibleNowAndRemainingUnits() + { + // Arrange: three eligible movies, one of them already queued; the queued one is counted in EligibleNow but excluded from RemainingUnits + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = currentCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = _fixture.TimeProvider.GetUtcNow(), + IsDryRun = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + QueueRecord[] queuedRecords = + [ + new() { Id = 1, Title = "Movie 3", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 3, TrackedDownloadState = "downloading" } + ]; + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns(queuedRecords.ToList()); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 3, Title = "Movie 3", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await mockArrClient.Received(1).SearchItemAsync(radarrInstance, Arg.Any()); + + var cycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == currentCycleId); + cycle.SearchedUnits.ShouldBe(1); + cycle.EligibleNow.ShouldBe(3); + cycle.RemainingUnits.ShouldBe(1); + } + + [Fact] + public async Task ExecuteAsync_Sonarr_EligibleNow_CountsSeasonsNotSeries() + { + // Arrange: a single series with two eligible seasons + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Series Ten", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } } + ])); + + var pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30); + StubSonarrWanted(sonarrInstance, + [ + new SearchableEpisode { Id = 100, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }, + new SearchableEpisode { Id = 101, SeriesId = 10, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false } + ]); + + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id); + instanceConfig.TotalEligibleItems.ShouldBe(1); + + var cycle = await _fixture.EventsContext.SeekerCycles + .FirstAsync(c => c.ArrInstanceId == sonarrInstance.Id && !c.IsDryRun); + cycle.EligibleNow.ShouldBe(2); + } + + [Fact] + public async Task ExecuteAsync_Radarr_CycleLifecycle_CompletesWaitsThenOpensNextCycle() + { + // Arrange: a cycle that already searched every eligible movie + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var firstCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + var firstCycleStartedAt = now.AddDays(-2); + + var instanceConfig = new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = firstCycleId, + MinCycleTimeDays = 7 + }; + _fixture.DataContext.SeekerInstanceConfigs.Add(instanceConfig); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = firstCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = firstCycleStartedAt, + EligibleAtStart = 2, + EligibleNow = 2, + SearchedUnits = 2, + IsDryRun = false + }); + + foreach (long movieId in new[] { 1L, 2L }) + { + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = movieId, + ItemType = InstanceType.Radarr, + CycleId = firstCycleId, + LastSearchedAt = firstCycleStartedAt, + ItemTitle = $"Movie {movieId}" + }); + } + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + List movies = + [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] }, + new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]; + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable(movies)); + StubRadarrWanted(radarrInstance, movies); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Act 1: MinCycleTimeDays has not elapsed yet: the cycle completes but no new one opens + await CreateSut().ExecuteAsync(); + + // Assert 1 + await mockArrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any()); + + var reloadedConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + reloadedConfig.CurrentCycleId.ShouldBe(firstCycleId); + + var firstCycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == firstCycleId); + firstCycle.CompletedAt.ShouldNotBeNull(); + firstCycle.CompletionReason.ShouldBe(SeekerCycleCompletionReason.Completed); + + // Act 2: MinCycleTimeDays has now elapsed: a new cycle opens and a search happens + _fixture.TimeProvider.Advance(TimeSpan.FromDays(8)); + await CreateSut().ExecuteAsync(); + + // Assert 2 + await mockArrClient.Received(1).SearchItemAsync(radarrInstance, Arg.Any()); + + reloadedConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + reloadedConfig.CurrentCycleId.ShouldNotBe(firstCycleId); + + var newCycle = await _fixture.EventsContext.SeekerCycles + .FirstAsync(c => c.Id == reloadedConfig.CurrentCycleId); + newCycle.EligibleAtStart.ShouldBe(2); + newCycle.CompletedAt.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteAsync_Radarr_EmptyCycle_NeverCompletes() + { + // Arrange: an open cycle with zero searches and nothing eligible in the library + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var currentCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = currentCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = currentCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = _fixture.TimeProvider.GetUtcNow(), + SearchedUnits = 0, + IsDryRun = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([])); + StubRadarrWanted(radarrInstance, []); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the empty cycle stays open + await mockArrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any()); + + var cycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == currentCycleId); + cycle.CompletedAt.ShouldBeNull(); + cycle.CompletionReason.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteAsync_Radarr_DryRun_OpensOwnCycle_LeavesLiveCycleAndRowUntouched() + { + // Arrange: a live cycle row and a live history row for the same movie already exist + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var liveCycleId = Guid.NewGuid(); + var now = _fixture.TimeProvider.GetUtcNow(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = liveCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = liveCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = now, + SearchedUnits = 0, + IsDryRun = false + }); + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = liveCycleId, + LastSearchedAt = now, + ItemTitle = "Movie 1", + IsDryRun = false + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + // Act: a dry run searches the same movie + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + await CreateSut().ExecuteAsync(); + + // Assert: a new dry cycle was opened, distinct from the live one + var dryCycle = await _fixture.EventsContext.SeekerCycles + .FirstAsync(c => c.ArrInstanceId == radarrInstance.Id && c.IsDryRun); + dryCycle.Id.ShouldNotBe(liveCycleId); + + var liveCycle = await _fixture.EventsContext.SeekerCycles.FirstAsync(c => c.Id == liveCycleId); + liveCycle.SearchedUnits.ShouldBe(0); + liveCycle.CompletedAt.ShouldBeNull(); + + var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs + .FirstAsync(s => s.ArrInstanceId == radarrInstance.Id); + instanceConfig.CurrentCycleId.ShouldBe(liveCycleId); + + var historyRows = await _fixture.EventsContext.SeekerHistory + .Where(h => h.ArrInstanceId == radarrInstance.Id && h.ExternalItemId == 1) + .ToListAsync(); + historyRows.Count.ShouldBe(2); + historyRows.ShouldContain(h => !h.IsDryRun && h.CycleId == liveCycleId); + historyRows.ShouldContain(h => h.IsDryRun && h.CycleId == dryCycle.Id); + } + + [Fact] + public async Task ExecuteAsync_Radarr_SearchInNewCycle_ReusesExistingHistoryRow() + { + // Arrange: the movie was already searched once in an older cycle + var config = await _fixture.DataContext.SeekerConfigs.FirstAsync(); + config.SearchEnabled = true; + config.ProactiveSearchEnabled = true; + await _fixture.DataContext.SaveChangesAsync(); + + var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + var oldCycleId = Guid.NewGuid(); + var newCycleId = Guid.NewGuid(); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + MonitoredOnly = false, + CurrentCycleId = newCycleId + }); + + _fixture.EventsContext.SeekerCycles.Add(new SeekerCycle + { + Id = newCycleId, + ArrInstanceId = radarrInstance.Id, + StartedAt = _fixture.TimeProvider.GetUtcNow(), + IsDryRun = false + }); + _fixture.EventsContext.SeekerHistory.Add(new SeekerHistory + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 1, + ItemType = InstanceType.Radarr, + CycleId = oldCycleId, + LastSearchedAt = _fixture.TimeProvider.GetUtcNow().AddDays(-10), + ItemTitle = "Movie 1", + SearchCount = 1, + LastOutcome = SeekerSearchOutcome.Grabbed + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + var mockArrClient = Substitute.For(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + StubRadarrWanted(radarrInstance, [ + new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ]); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert: the single history row moved to the new cycle instead of a second row being created + var historyRows = await _fixture.EventsContext.SeekerHistory + .Where(h => h.ArrInstanceId == radarrInstance.Id && h.ExternalItemId == 1) + .ToListAsync(); + historyRows.Count.ShouldBe(1); + historyRows[0].CycleId.ShouldBe(newCycleId); + historyRows[0].SearchCount.ShouldBe(2); + historyRows[0].LastOutcome.ShouldBeNull(); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index ae12bf75..093444c2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -436,32 +436,13 @@ public sealed class Seeker : IHandler HashSet<(long SeriesId, long SeasonNumber)> recentlyGrabbedSeasons, CancellationToken cancellationToken) { - // Dry run tracks its own cycle through dry history rows. - Guid effectiveCycleId; - List currentCycleHistory; + SeekerCycle cycle = await GetCurrentCycleAsync(arrInstance.Id, instanceConfig, isDryRun, cancellationToken); - if (isDryRun) - { - SeekerHistory? latestDryCycleRow = await _eventsContext.SeekerHistory - .AsNoTracking() - .Where(h => h.ArrInstanceId == arrInstance.Id - && h.IsDryRun - && h.CycleId != instanceConfig.CurrentCycleId) - .OrderByDescending(h => h.LastSearchedAt) - .FirstOrDefaultAsync(cancellationToken); - - effectiveCycleId = latestDryCycleRow?.CycleId ?? instanceConfig.CurrentCycleId; - } - else - { - effectiveCycleId = instanceConfig.CurrentCycleId; - } - - currentCycleHistory = await _eventsContext.SeekerHistory + List currentCycleHistory = await _eventsContext.SeekerHistory .AsNoTracking() .Where(h => h.ArrInstanceId == arrInstance.Id - && h.CycleId == effectiveCycleId - && (isDryRun || !h.IsDryRun)) + && h.CycleId == cycle.Id + && h.IsDryRun == isDryRun) .ToListAsync(cancellationToken); // Load all history for stale cleanup @@ -491,7 +472,7 @@ public sealed class Seeker : IHandler .ToHashSet(); queuedMovieIds.UnionWith(recentlyGrabbedMovieIds); - result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, effectiveCycleId, isDryRun, queuedMovieIds, cancellationToken); + result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, cycle, isDryRun, queuedMovieIds, cancellationToken); } else { @@ -501,9 +482,11 @@ public sealed class Seeker : IHandler .ToHashSet(); queuedSeasons.UnionWith(recentlyGrabbedSeasons); - result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, effectiveCycleId, isDryRun, queuedSeasons, cancellationToken); + result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, cycle, isDryRun, queuedSeasons, cancellationToken); } + await _eventsContext.SaveChangesAsync(cancellationToken); + if (result.Candidates.Count == 0) { _logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name); @@ -531,12 +514,15 @@ public sealed class Seeker : IHandler long commandId = await arrClient.SearchItemAsync(arrInstance, searchItem); Guid eventId = await _eventPublisher.PublishSearchTriggered( - candidate.Name, SeekerSearchType.Proactive, candidate.Reason, result.CycleId, isDryRun); + candidate.Name, SeekerSearchType.Proactive, candidate.Reason, result.Cycle.Id, isDryRun); _logger.LogInformation("Search triggered for {Item} ({Reason}) | {InstanceUrl}", candidate.Name, candidate.Reason, arrInstance.Url); - await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, result.CycleId, - [candidate.ItemId], [candidate.Name], candidate.SeasonNumber, isDryRun); + result.Cycle.SearchedUnits++; + result.Cycle.RemainingUnits = Math.Max(0, result.Cycle.RemainingUnits - 1); + + await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, result.Cycle.Id, + candidate.ItemId, candidate.Name, candidate.SeasonNumber, isDryRun); if (!isDryRun) { @@ -548,7 +534,6 @@ public sealed class Seeker : IHandler if (!isDryRun) { await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds); - await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId); } return true; @@ -559,7 +544,7 @@ public sealed class Seeker : IHandler ArrInstance arrInstance, SeekerInstanceConfig instanceConfig, Dictionary searchHistory, - Guid cycleId, + SeekerCycle cycle, bool isDryRun, HashSet queuedMovieIds, CancellationToken cancellationToken) @@ -634,7 +619,7 @@ public sealed class Seeker : IHandler catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.NotFound) { _logger.LogWarning(ex, "Skipping proactive search for {InstanceName}, it needs Radarr v5.6 or newer", arrInstance.Name); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } List candidates = new(); @@ -658,11 +643,14 @@ public sealed class Seeker : IHandler candidates.Add(movie); } - instanceConfig.TotalEligibleItems = candidates.Count; + int eligibleCount = candidates.Count; + instanceConfig.TotalEligibleItems = eligibleCount; + cycle.EligibleNow = eligibleCount; + cycle.RemainingUnits = 0; if (candidates.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } // Exclude movies already in the download queue @@ -682,39 +670,22 @@ public sealed class Seeker : IHandler if (candidates.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } } - // Check for cycle completion: all candidates already searched in current cycle - bool cycleComplete = candidates.All(m => searchHistory.ContainsKey(m.Id)); - if (cycleComplete) + cycle.RemainingUnits = candidates.Count(m => !searchHistory.ContainsKey(m.Id)); + + if (cycle.RemainingUnits == 0) { - // Respect MinCycleTimeDays even when cycle completes due to queue filtering - DateTimeOffset? cycleStartedAt = searchHistory.Count > 0 ? searchHistory.Values.Min() : null; - if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt)) + SeekerCycle? next = await CompleteCycleAsync(cycle, instanceConfig, isDryRun, eligibleCount, arrInstance.Name); + if (next is null) { - _logger.LogDebug( - "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", - instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; - } - - _logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle", - candidates.Count, arrInstance.Name); - - if (isDryRun) - { - // Live cycle id stays untouched. - cycleId = Guid.NewGuid(); - } - else - { - instanceConfig.CurrentCycleId = Guid.NewGuid(); - await _dataContext.SaveChangesAsync(); - cycleId = instanceConfig.CurrentCycleId; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } + cycle = next; + cycle.RemainingUnits = candidates.Count; searchHistory = new Dictionary(); } @@ -762,7 +733,7 @@ public sealed class Seeker : IHandler movie.Title, arrInstance.Name, reason); } - return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = searchCandidates, AllLibraryIds = allLibraryIds, Cycle = cycle }; } private async Task ProcessSonarrAsync( @@ -771,7 +742,7 @@ public sealed class Seeker : IHandler SeekerInstanceConfig instanceConfig, Dictionary seriesSearchHistory, List currentCycleHistory, - Guid cycleId, + SeekerCycle cycle, bool isDryRun, HashSet<(long SeriesId, long SeasonNumber)> queuedSeasons, CancellationToken cancellationToken) @@ -872,10 +843,12 @@ public sealed class Seeker : IHandler .ToDictionary(g => g.Key, g => g.Select(q => q.Reason).ToList()); instanceConfig.TotalEligibleItems = seasons.Keys.Select(s => s.SeriesId).Distinct().Count(); + cycle.EligibleNow = seasons.Count; + cycle.RemainingUnits = 0; if (seasons.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } List<(long SeriesId, int SeasonNumber)> available = seasons.Keys @@ -891,7 +864,7 @@ public sealed class Seeker : IHandler if (available.Count == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } HashSet<(long SeriesId, int SeasonNumber)> searchedSeasons = currentCycleHistory @@ -902,35 +875,20 @@ public sealed class Seeker : IHandler .Where(s => !searchedSeasons.Contains(s)) .ToList(); + cycle.RemainingUnits = remaining.Count; + if (remaining.Count == 0) { - // Respect MinCycleTimeDays even when cycle completes due to queue filtering - DateTimeOffset? cycleStartedAt = seriesSearchHistory.Count > 0 ? seriesSearchHistory.Values.Min() : null; - if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt)) + SeekerCycle? next = await CompleteCycleAsync(cycle, instanceConfig, isDryRun, seasons.Count, arrInstance.Name); + if (next is null) { - _logger.LogDebug( - "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", - instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, CycleId = cycleId }; - } - - _logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle", - instanceConfig.TotalEligibleItems, arrInstance.Name); - - if (isDryRun) - { - // Live cycle id stays untouched. - cycleId = Guid.NewGuid(); - } - else - { - instanceConfig.CurrentCycleId = Guid.NewGuid(); - await _dataContext.SaveChangesAsync(); - cycleId = instanceConfig.CurrentCycleId; + return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; } + cycle = next; seriesSearchHistory = new Dictionary(); remaining = available; + cycle.RemainingUnits = remaining.Count; } Dictionary> remainingSeasonsBySeries = remaining @@ -993,7 +951,7 @@ public sealed class Seeker : IHandler } ], AllLibraryIds = allLibraryIds, - CycleId = cycleId, + Cycle = cycle, }; } @@ -1016,76 +974,48 @@ public sealed class Seeker : IHandler Guid arrInstanceId, InstanceType instanceType, Guid cycleId, - List searchedIds, - List? itemTitles = null, - int seasonNumber = 0, - bool isDryRun = false) + long externalItemId, + string itemTitle, + int seasonNumber, + bool isDryRun) { - var now = _timeProvider.GetUtcNow(); + DateTimeOffset now = _timeProvider.GetUtcNow(); - for (int i = 0; i < searchedIds.Count; i++) + SeekerHistory? existing = await _eventsContext.SeekerHistory + .FirstOrDefaultAsync(h => + h.ArrInstanceId == arrInstanceId + && h.ExternalItemId == externalItemId + && h.ItemType == instanceType + && h.SeasonNumber == seasonNumber + && h.EpisodeId == 0 + && h.IsDryRun == isDryRun); + + if (existing is not null) { - long id = searchedIds[i]; - string title = itemTitles != null && i < itemTitles.Count ? itemTitles[i] : string.Empty; - - SeekerHistory? existing = await _eventsContext.SeekerHistory - .FirstOrDefaultAsync(h => - h.ArrInstanceId == arrInstanceId - && h.ExternalItemId == id - && h.ItemType == instanceType - && h.SeasonNumber == seasonNumber - && h.CycleId == cycleId); - - if (existing is not null) + existing.CycleId = cycleId; + existing.LastSearchedAt = now; + existing.SearchCount++; + existing.LastOutcome = null; + existing.ItemTitle = itemTitle; + } + else + { + _eventsContext.SeekerHistory.Add(new SeekerHistory { - if (SkipOrResetDryRunRow(existing, isDryRun, title)) - { - continue; - } - - existing.LastSearchedAt = now; - existing.SearchCount++; - if (!string.IsNullOrEmpty(title)) - { - existing.ItemTitle = title; - } - } - else - { - _eventsContext.SeekerHistory.Add(new SeekerHistory - { - ArrInstanceId = arrInstanceId, - ExternalItemId = id, - ItemType = instanceType, - SeasonNumber = seasonNumber, - CycleId = cycleId, - LastSearchedAt = now, - ItemTitle = title, - IsDryRun = isDryRun, - }); - } + ArrInstanceId = arrInstanceId, + ExternalItemId = externalItemId, + ItemType = instanceType, + SeasonNumber = seasonNumber, + CycleId = cycleId, + LastSearchedAt = now, + ItemTitle = itemTitle, + IsDryRun = isDryRun, + }); } await _eventsContext.SaveChangesAsync(); } - private bool SkipOrResetDryRunRow(SeekerHistory existing, bool isDryRun, string title) - { - if (isDryRun && !existing.IsDryRun) - { - _logger.LogDebug("[DRY RUN] skipping search history update for live row | {Title}", title); - return true; - } - - if (!isDryRun && existing.IsDryRun) - { - existing.IsDryRun = false; - existing.SearchCount = 0; - } - - return false; - } - private async Task SaveCommandTrackerAsync( long commandId, Guid eventId, @@ -1142,38 +1072,135 @@ public sealed class Seeker : IHandler } /// - /// Removes history entries from previous cycles that are older than 30 days. - /// Recent cycle history is retained for statistics and history viewing. + /// Loads the cycle the instance is in, creating its row when missing. + /// Dry run follows its own newest cycle and never touches the live one. /// - private async Task CleanupOldCycleHistoryAsync(ArrInstance arrInstance, Guid currentCycleId) + private async Task GetCurrentCycleAsync( + Guid arrInstanceId, + SeekerInstanceConfig instanceConfig, + bool isDryRun, + CancellationToken cancellationToken) { - DateTimeOffset cutoff = _timeProvider.GetUtcNow().AddDays(-30); - - int deleted = await _eventsContext.SeekerHistory - .Where(h => h.ArrInstanceId == arrInstance.Id - && h.CycleId != currentCycleId - && h.LastSearchedAt < cutoff) - .ExecuteDeleteAsync(); - - if (deleted > 0) + if (isDryRun) { - _logger.LogDebug("Cleaned up {Count} old cycle history entries (>30 days) for instance {InstanceName}", - deleted, arrInstance.Name); + SeekerCycle? dryCycle = await _eventsContext.SeekerCycles + .Where(c => c.ArrInstanceId == arrInstanceId && c.IsDryRun) + .OrderByDescending(c => c.StartedAt) + .FirstOrDefaultAsync(cancellationToken); + + return dryCycle ?? OpenCycle(arrInstanceId, isDryRun: true, eligibleAtStart: 0); } + + Guid currentCycleId = instanceConfig.CurrentCycleId; + List openOrCurrent = await _eventsContext.SeekerCycles + .Where(c => c.ArrInstanceId == arrInstanceId + && !c.IsDryRun + && (c.Id == currentCycleId || c.CompletedAt == null)) + .ToListAsync(cancellationToken); + + // The migration guesses the open cycle from search events, the config holds the real one. + foreach (SeekerCycle stale in openOrCurrent.Where(c => c.Id != currentCycleId)) + { + stale.CompletedAt = await _eventsContext.Events + .Where(e => e.CycleId == stale.Id) + .OrderByDescending(e => e.Timestamp) + .Select(e => (DateTimeOffset?)e.Timestamp) + .FirstOrDefaultAsync(cancellationToken) ?? stale.StartedAt; + stale.CompletionReason = SeekerCycleCompletionReason.Completed; + } + + SeekerCycle? cycle = openOrCurrent.FirstOrDefault(c => c.Id == currentCycleId); + if (cycle is not null) + { + return cycle; + } + + List searchedAt = await _eventsContext.SeekerHistory + .Where(h => h.ArrInstanceId == arrInstanceId && h.CycleId == currentCycleId && !h.IsDryRun) + .Select(h => h.LastSearchedAt) + .ToListAsync(cancellationToken); + + cycle = OpenCycle(arrInstanceId, isDryRun: false, eligibleAtStart: 0, currentCycleId); + cycle.SearchedUnits = searchedAt.Count; + if (searchedAt.Count > 0) + { + cycle.StartedAt = searchedAt.Min(); + } + + return cycle; + } + + private SeekerCycle OpenCycle(Guid arrInstanceId, bool isDryRun, int eligibleAtStart, Guid? id = null) + { + SeekerCycle cycle = new() + { + Id = id ?? Guid.NewGuid(), + ArrInstanceId = arrInstanceId, + StartedAt = _timeProvider.GetUtcNow(), + EligibleAtStart = eligibleAtStart, + EligibleNow = eligibleAtStart, + IsDryRun = isDryRun, + }; + + _eventsContext.SeekerCycles.Add(cycle); + return cycle; + } + + /// + /// Closes a cycle with nothing left to search and opens the next one once the minimum cycle time has passed. + /// Returns the new cycle, or null while the instance has to wait. + /// + private async Task CompleteCycleAsync( + SeekerCycle cycle, + SeekerInstanceConfig instanceConfig, + bool isDryRun, + int eligibleCount, + string instanceName) + { + // An empty cycle stays open, so an instance with nothing eligible does not complete a cycle every week. + if (cycle.SearchedUnits == 0) + { + return null; + } + + if (cycle.CompletedAt is null) + { + cycle.CompletedAt = _timeProvider.GetUtcNow(); + cycle.CompletionReason = SeekerCycleCompletionReason.Completed; + + _logger.LogInformation("Cycle completed on {InstanceName} after {Count} searches", + instanceName, cycle.SearchedUnits); + } + + if (ShouldWaitForMinCycleTime(instanceConfig, cycle.StartedAt)) + { + _logger.LogDebug( + "skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}", + instanceConfig.MinCycleTimeDays, cycle.StartedAt, instanceName); + return null; + } + + SeekerCycle next = OpenCycle(cycle.ArrInstanceId, isDryRun, eligibleCount); + + if (!isDryRun) + { + instanceConfig.CurrentCycleId = next.Id; + await _dataContext.SaveChangesAsync(); + } + + _logger.LogInformation("Starting new cycle on {InstanceName} with {Count} eligible items", + instanceName, eligibleCount); + + return next; } /// /// Checks whether the minimum cycle time constraint prevents starting a new cycle. /// Returns true if the cycle started recently and MinCycleTimeDays has not yet elapsed. /// - private bool ShouldWaitForMinCycleTime(SeekerInstanceConfig instanceConfig, DateTimeOffset? cycleStartedAt) + private bool ShouldWaitForMinCycleTime(SeekerInstanceConfig instanceConfig, DateTimeOffset cycleStartedAt) { - if (cycleStartedAt is null) - { - return false; - } - - var elapsed = _timeProvider.GetUtcNow() - cycleStartedAt.Value; + TimeSpan elapsed = _timeProvider.GetUtcNow() - cycleStartedAt; return elapsed.TotalDays < instanceConfig.MinCycleTimeDays; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs index 4f0ae268..9467e2af 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs @@ -1,3 +1,5 @@ +using Cleanuparr.Persistence.Models.State; + namespace Cleanuparr.Infrastructure.Features.Seeker; /// @@ -10,7 +12,6 @@ internal sealed record SeekerProcessResult /// /// Cycle the candidates belong to. - /// A dry run gets a simulated cycle that never reaches the config. /// - public required Guid CycleId { get; init; } + public required SeekerCycle Cycle { get; init; } } diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.Designer.cs new file mode 100644 index 00000000..90c31040 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.Designer.cs @@ -0,0 +1,837 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Events +{ + [DbContext(typeof(EventsContext))] + [Migration("20261017173923_AddSeekerCycles")] + partial class AddSeekerCycles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("events") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CleanReason") + .HasColumnType("text") + .HasColumnName("clean_reason"); + + b.Property("CleanedCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("cleaned_category"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CycleId") + .HasColumnType("uuid") + .HasColumnName("cycle_id"); + + b.Property("DeleteReason") + .HasColumnType("text") + .HasColumnName("delete_reason"); + + b.Property("DownloadClientId") + .HasColumnType("uuid") + .HasColumnName("download_client_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.PrimitiveCollection>("FailedImportReasons") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("failed_import_reasons"); + + b.PrimitiveCollection>("GrabbedItems") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("grabbed_items"); + + b.Property("IsCategoryTag") + .HasColumnType("boolean") + .HasColumnName("is_category_tag"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("message"); + + b.Property("NewCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("new_category"); + + b.Property("OldCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("old_category"); + + b.Property("RemoveFromClient") + .HasColumnType("boolean") + .HasColumnName("remove_from_client"); + + b.Property("SearchReason") + .HasColumnType("text") + .HasColumnName("search_reason"); + + b.Property("SearchStatus") + .HasColumnType("text") + .HasColumnName("search_status"); + + b.Property("SearchType") + .HasColumnType("text") + .HasColumnName("search_type"); + + b.Property("SeedRatio") + .HasColumnType("double precision") + .HasColumnName("seed_ratio"); + + b.Property("SeedingTimeHours") + .HasColumnType("double precision") + .HasColumnName("seeding_time_hours"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("integer") + .HasColumnName("strike_count"); + + b.Property("StrikeId") + .HasColumnType("uuid") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("uuid") + .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("DeleteReason") + .HasDatabaseName("ix_events_delete_reason"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + 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", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("text") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("text") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("IsResolved") + .HasColumnType("boolean") + .HasColumnName("is_resolved"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("message"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("integer") + .HasColumnName("strike_count"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + 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.HasIndex("Type", "ItemHash") + .IsUnique() + .HasDatabaseName("ix_manual_events_type_item_hash") + .HasFilter("is_resolved = false"); + + b.ToTable("manual_events", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("integer") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("integer") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("bigint") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("boolean") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("quality_profile_name"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_i"); + + b.ToTable("custom_format_score_entries", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("integer") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("integer") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_i"); + + b.ToTable("custom_format_score_history", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("boolean") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("boolean") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("boolean") + .HasColumnName("is_returning"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_download_items"); + + b.HasIndex("DownloadId") + .IsUnique() + .HasDatabaseName("ix_download_items_download_id"); + + b.ToTable("download_items", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("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", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("bigint") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("text") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("bigint") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("bigint") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("text") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("integer") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("integer") + .HasColumnName("eligible_now"); + + b.Property("GrabbedUnits") + .HasColumnType("integer") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("RemainingUnits") + .HasColumnType("integer") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("integer") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("uuid") + .HasColumnName("cycle_id"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("Grabs") + .HasColumnType("integer") + .HasColumnName("grabs"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("LastOutcome") + .HasColumnType("text") + .HasColumnName("last_outcome"); + + b.Property("LastSearchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("integer") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_s"); + + b.ToTable("seeker_history", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("uuid") + .HasColumnName("download_item_id"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("last_downloaded_bytes"); + + b.Property("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", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Events") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.SetNull) + .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.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.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 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.cs new file mode 100644 index 00000000..bf4ce81c --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261017173923_AddSeekerCycles.cs @@ -0,0 +1,152 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Events +{ + /// + public partial class AddSeekerCycles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_s", + schema: "events", + table: "seeker_history"); + + migrationBuilder.AddColumn( + name: "episode_id", + schema: "events", + table: "seeker_history", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "grabs", + schema: "events", + table: "seeker_history", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "last_outcome", + schema: "events", + table: "seeker_history", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "seeker_cycles", + schema: "events", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + arr_instance_id = table.Column(type: "uuid", nullable: false), + started_at = table.Column(type: "timestamp with time zone", nullable: false), + completed_at = table.Column(type: "timestamp with time zone", nullable: true), + completion_reason = table.Column(type: "text", nullable: true), + eligible_at_start = table.Column(type: "integer", nullable: false), + eligible_now = table.Column(type: "integer", nullable: false), + remaining_units = table.Column(type: "integer", nullable: false), + searched_units = table.Column(type: "integer", nullable: false), + grabbed_units = table.Column(type: "integer", nullable: false), + is_dry_run = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_cycles", x => x.id); + }); + + // History becomes one row per unit, so a unit's rows from past cycles fold into its newest one. + migrationBuilder.Sql(@" +UPDATE events.seeker_history h +SET search_count = ( + SELECT SUM(h2.search_count) FROM events.seeker_history h2 + WHERE h2.arr_instance_id = h.arr_instance_id AND h2.external_item_id = h.external_item_id AND h2.item_type = h.item_type AND h2.season_number = h.season_number AND h2.is_dry_run = h.is_dry_run) +WHERE NOT EXISTS ( + SELECT 1 FROM events.seeker_history h2 + WHERE h2.arr_instance_id = h.arr_instance_id AND h2.external_item_id = h.external_item_id AND h2.item_type = h.item_type AND h2.season_number = h.season_number AND h2.is_dry_run = h.is_dry_run + AND (h2.last_searched_at > h.last_searched_at + OR (h2.last_searched_at = h.last_searched_at AND h2.id > h.id)));"); + + migrationBuilder.Sql(@" +DELETE FROM events.seeker_history h +WHERE EXISTS ( + SELECT 1 FROM events.seeker_history h2 + WHERE h2.arr_instance_id = h.arr_instance_id AND h2.external_item_id = h.external_item_id AND h2.item_type = h.item_type AND h2.season_number = h.season_number AND h2.is_dry_run = h.is_dry_run + AND (h2.last_searched_at > h.last_searched_at + OR (h2.last_searched_at = h.last_searched_at AND h2.id > h.id)));"); + + // Past cycles exist only as a cycle id on search events. + // The newest cycle of each instance stays open, Seeker reconciles it with the live cycle id on its next run. + migrationBuilder.Sql(@" +INSERT INTO events.seeker_cycles (id, arr_instance_id, started_at, completed_at, completion_reason, eligible_at_start, eligible_now, remaining_units, searched_units, grabbed_units, is_dry_run) +SELECT e.cycle_id, e.arr_instance_id, MIN(e.timestamp), MAX(e.timestamp), 'completed', COUNT(*), 0, 0, COUNT(*), + SUM(CASE WHEN cardinality(e.grabbed_items) > 0 THEN 1 ELSE 0 END), MIN(e.is_dry_run::int) = 1 +FROM events.events e +WHERE e.event_type = 'searchtriggered' AND e.cycle_id IS NOT NULL AND e.arr_instance_id IS NOT NULL +GROUP BY e.cycle_id, e.arr_instance_id;"); + + migrationBuilder.Sql(@" +UPDATE events.seeker_cycles c +SET completed_at = NULL, completion_reason = NULL +WHERE NOT EXISTS ( + SELECT 1 FROM events.seeker_cycles c2 + WHERE c2.arr_instance_id = c.arr_instance_id + AND c2.is_dry_run = c.is_dry_run + AND c2.started_at > c.started_at);"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_s", + schema: "events", + table: "seeker_history", + columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "episode_id", "is_dry_run" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_seeker_cycles_arr_instance_id", + schema: "events", + table: "seeker_cycles", + column: "arr_instance_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "seeker_cycles", + schema: "events"); + + migrationBuilder.DropIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_s", + schema: "events", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "episode_id", + schema: "events", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "grabs", + schema: "events", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "last_outcome", + schema: "events", + table: "seeker_history"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_s", + schema: "events", + table: "seeker_history", + columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "cycle_id" }, + unique: true); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs index 3dd63a44..533cad69 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs @@ -599,6 +599,62 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events b.ToTable("seeker_command_trackers", "events"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("text") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("integer") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("integer") + .HasColumnName("eligible_now"); + + b.Property("GrabbedUnits") + .HasColumnType("integer") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("RemainingUnits") + .HasColumnType("integer") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("integer") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", "events"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => { b.Property("Id") @@ -614,10 +670,18 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("uuid") .HasColumnName("cycle_id"); + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + b.Property("ExternalItemId") .HasColumnType("bigint") .HasColumnName("external_item_id"); + b.Property("Grabs") + .HasColumnType("integer") + .HasColumnName("grabs"); + b.Property("IsDryRun") .HasColumnType("boolean") .HasColumnName("is_dry_run"); @@ -632,6 +696,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("text") .HasColumnName("item_type"); + b.Property("LastOutcome") + .HasColumnType("text") + .HasColumnName("last_outcome"); + b.Property("LastSearchedAt") .HasColumnType("timestamp with time zone") .HasColumnName("last_searched_at"); @@ -647,7 +715,7 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events b.HasKey("Id") .HasName("pk_seeker_history"); - b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") .IsUnique() .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_s"); diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.Designer.cs new file mode 100644 index 00000000..685d5f5d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.Designer.cs @@ -0,0 +1,840 @@ +// +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("20261017173918_AddSeekerCycles")] + partial class AddSeekerCycles + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CleanReason") + .HasColumnType("TEXT") + .HasColumnName("clean_reason"); + + b.Property("CleanedCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("cleaned_category"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("DeleteReason") + .HasColumnType("TEXT") + .HasColumnName("delete_reason"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.PrimitiveCollection("FailedImportReasons") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_reasons"); + + b.PrimitiveCollection("GrabbedItems") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grabbed_items"); + + b.Property("IsCategoryTag") + .HasColumnType("INTEGER") + .HasColumnName("is_category_tag"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("NewCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("new_category"); + + b.Property("OldCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("old_category"); + + b.Property("RemoveFromClient") + .HasColumnType("INTEGER") + .HasColumnName("remove_from_client"); + + b.Property("SearchReason") + .HasColumnType("TEXT") + .HasColumnName("search_reason"); + + b.Property("SearchStatus") + .HasColumnType("TEXT") + .HasColumnName("search_status"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeedRatio") + .HasColumnType("REAL") + .HasColumnName("seed_ratio"); + + b.Property("SeedingTimeHours") + .HasColumnType("REAL") + .HasColumnName("seeding_time_hours"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("INTEGER") + .HasColumnName("strike_count"); + + b.Property("StrikeId") + .HasColumnType("TEXT") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("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("DeleteReason") + .HasDatabaseName("ix_events_delete_reason"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("INTEGER") + .HasColumnName("strike_count"); + + b.Property("Timestamp") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + 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.HasIndex("Type", "ItemHash") + .IsUnique() + .HasDatabaseName("ix_manual_events_type_item_hash") + .HasFilter("\"is_resolved\" = 0"); + + b.ToTable("manual_events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("INTEGER") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("INTEGER") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("INTEGER") + .HasColumnName("is_returning"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("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.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("TEXT") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("INTEGER") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("INTEGER") + .HasColumnName("eligible_now"); + + b.Property("GrabbedUnits") + .HasColumnType("INTEGER") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("RemainingUnits") + .HasColumnType("INTEGER") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("INTEGER") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("Grabs") + .HasColumnType("INTEGER") + .HasColumnName("grabs"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastOutcome") + .HasColumnType("TEXT") + .HasColumnName("last_outcome"); + + b.Property("LastSearchedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_episode_id_is_dry_run"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("TEXT") + .HasColumnName("download_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("INTEGER") + .HasColumnName("last_downloaded_bytes"); + + b.Property("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") + .OnDelete(DeleteBehavior.SetNull) + .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.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.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 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.cs new file mode 100644 index 00000000..08e29690 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261017173918_AddSeekerCycles.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddSeekerCycles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id", + table: "seeker_history"); + + migrationBuilder.AddColumn( + name: "episode_id", + table: "seeker_history", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "grabs", + table: "seeker_history", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "last_outcome", + table: "seeker_history", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "seeker_cycles", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + arr_instance_id = table.Column(type: "TEXT", nullable: false), + started_at = table.Column(type: "TEXT", nullable: false), + completed_at = table.Column(type: "TEXT", nullable: true), + completion_reason = table.Column(type: "TEXT", nullable: true), + eligible_at_start = table.Column(type: "INTEGER", nullable: false), + eligible_now = table.Column(type: "INTEGER", nullable: false), + remaining_units = table.Column(type: "INTEGER", nullable: false), + searched_units = table.Column(type: "INTEGER", nullable: false), + grabbed_units = table.Column(type: "INTEGER", nullable: false), + is_dry_run = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_seeker_cycles", x => x.id); + }); + + // History becomes one row per unit, so a unit's rows from past cycles fold into its newest one. + migrationBuilder.Sql(@" +UPDATE seeker_history +SET search_count = ( + SELECT SUM(h2.search_count) FROM seeker_history h2 + WHERE h2.arr_instance_id = seeker_history.arr_instance_id AND h2.external_item_id = seeker_history.external_item_id AND h2.item_type = seeker_history.item_type AND h2.season_number = seeker_history.season_number AND h2.is_dry_run = seeker_history.is_dry_run) +WHERE NOT EXISTS ( + SELECT 1 FROM seeker_history h2 + WHERE h2.arr_instance_id = seeker_history.arr_instance_id AND h2.external_item_id = seeker_history.external_item_id AND h2.item_type = seeker_history.item_type AND h2.season_number = seeker_history.season_number AND h2.is_dry_run = seeker_history.is_dry_run + AND (h2.last_searched_at > seeker_history.last_searched_at + OR (h2.last_searched_at = seeker_history.last_searched_at AND h2.id > seeker_history.id)));"); + + migrationBuilder.Sql(@" +DELETE FROM seeker_history +WHERE EXISTS ( + SELECT 1 FROM seeker_history h2 + WHERE h2.arr_instance_id = seeker_history.arr_instance_id AND h2.external_item_id = seeker_history.external_item_id AND h2.item_type = seeker_history.item_type AND h2.season_number = seeker_history.season_number AND h2.is_dry_run = seeker_history.is_dry_run + AND (h2.last_searched_at > seeker_history.last_searched_at + OR (h2.last_searched_at = seeker_history.last_searched_at AND h2.id > seeker_history.id)));"); + + // Past cycles exist only as a cycle id on search events. + // The newest cycle of each instance stays open, Seeker reconciles it with the live cycle id on its next run. + migrationBuilder.Sql(@" +INSERT INTO seeker_cycles (id, arr_instance_id, started_at, completed_at, completion_reason, eligible_at_start, eligible_now, remaining_units, searched_units, grabbed_units, is_dry_run) +SELECT e.cycle_id, e.arr_instance_id, MIN(e.timestamp), MAX(e.timestamp), 'completed', COUNT(*), 0, 0, COUNT(*), + SUM(CASE WHEN e.grabbed_items <> '[]' THEN 1 ELSE 0 END), MIN(e.is_dry_run) +FROM events e +WHERE e.event_type = 'searchtriggered' AND e.cycle_id IS NOT NULL AND e.arr_instance_id IS NOT NULL +GROUP BY e.cycle_id, e.arr_instance_id;"); + + migrationBuilder.Sql(@" +UPDATE seeker_cycles +SET completed_at = NULL, completion_reason = NULL +WHERE NOT EXISTS ( + SELECT 1 FROM seeker_cycles c2 + WHERE c2.arr_instance_id = seeker_cycles.arr_instance_id + AND c2.is_dry_run = seeker_cycles.is_dry_run + AND c2.started_at > seeker_cycles.started_at);"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_episode_id_is_dry_run", + table: "seeker_history", + columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "episode_id", "is_dry_run" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_seeker_cycles_arr_instance_id", + table: "seeker_cycles", + column: "arr_instance_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "seeker_cycles"); + + migrationBuilder.DropIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_episode_id_is_dry_run", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "episode_id", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "grabs", + table: "seeker_history"); + + migrationBuilder.DropColumn( + name: "last_outcome", + table: "seeker_history"); + + migrationBuilder.CreateIndex( + name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id", + table: "seeker_history", + columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "cycle_id" }, + unique: true); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs index 4ffbf793..aa39baa9 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs @@ -599,6 +599,63 @@ namespace Cleanuparr.Persistence.Migrations.Events b.ToTable("seeker_command_trackers", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("TEXT") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("INTEGER") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("INTEGER") + .HasColumnName("eligible_now"); + + b.Property("GrabbedUnits") + .HasColumnType("INTEGER") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("RemainingUnits") + .HasColumnType("INTEGER") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("INTEGER") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => { b.Property("Id") @@ -614,10 +671,18 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("cycle_id"); + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + b.Property("ExternalItemId") .HasColumnType("INTEGER") .HasColumnName("external_item_id"); + b.Property("Grabs") + .HasColumnType("INTEGER") + .HasColumnName("grabs"); + b.Property("IsDryRun") .HasColumnType("INTEGER") .HasColumnName("is_dry_run"); @@ -632,6 +697,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("item_type"); + b.Property("LastOutcome") + .HasColumnType("TEXT") + .HasColumnName("last_outcome"); + b.Property("LastSearchedAt") .IsRequired() .HasColumnType("TEXT") @@ -648,9 +717,9 @@ namespace Cleanuparr.Persistence.Migrations.Events b.HasKey("Id") .HasName("pk_seeker_history"); - b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") .IsUnique() - .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_episode_id_is_dry_run"); b.ToTable("seeker_history", (string)null); }); diff --git a/code/backend/Cleanuparr.Persistence/EventsContext.cs b/code/backend/Cleanuparr.Persistence/EventsContext.cs index f0f1ac0e..9d598ef9 100644 --- a/code/backend/Cleanuparr.Persistence/EventsContext.cs +++ b/code/backend/Cleanuparr.Persistence/EventsContext.cs @@ -26,6 +26,8 @@ public class EventsContext : DbContext public DbSet SeekerHistory { get; set; } + public DbSet SeekerCycles { get; set; } + public DbSet SearchQueue { get; set; } public DbSet CustomFormatScoreEntries { get; set; } @@ -123,7 +125,12 @@ public class EventsContext : DbContext modelBuilder.Entity(entity => { - entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.ItemType, s.SeasonNumber, s.CycleId }).IsUnique(); + entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.ItemType, s.SeasonNumber, s.EpisodeId, s.IsDryRun }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(s => s.ArrInstanceId); }); modelBuilder.Entity(entity => diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs new file mode 100644 index 00000000..a180a178 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Persistence.Models.State; + +/// +/// One Seeker cycle of an arr instance: every eligible unit searched once. +/// +public sealed record SeekerCycle +{ + /// + /// The cycle id, shared with the search events and units searched in it + /// + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// The arr instance this cycle belongs to + /// + public Guid ArrInstanceId { get; set; } + + /// + /// When the cycle opened + /// + public DateTimeOffset StartedAt { get; set; } + + /// + /// When the last eligible unit got searched. Null while the cycle runs. + /// + public DateTimeOffset? CompletedAt { get; set; } + + /// + /// Why the cycle ended. Null while the cycle runs. + /// + public SeekerCycleCompletionReason? CompletionReason { get; set; } + + /// + /// Eligible units when the cycle opened + /// + public int EligibleAtStart { get; set; } + + /// + /// Eligible units on the latest run + /// + public int EligibleNow { get; set; } + + /// + /// Eligible units on the latest run that this cycle has not searched and that are not in the queue + /// + public int RemainingUnits { get; set; } + + /// + /// Searches triggered in this cycle + /// + public int SearchedUnits { get; set; } + + /// + /// Searches in this cycle that grabbed a release + /// + public int GrabbedUnits { get; set; } + + /// + /// Whether the cycle ran in dry run mode + /// + public bool IsDryRun { get; set; } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs index 71f3a034..6e7e4de7 100644 --- a/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerHistory.cs @@ -6,8 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.Arr; namespace Cleanuparr.Persistence.Models.State; /// -/// Tracks the last time each media item was searched by the Seeker job. -/// Used by selection strategies to prioritize items that haven't been searched recently. +/// One Seeker search unit: a movie, a season, or an episode. +/// Tracks the last search and the lifetime results of searching it. /// public sealed record SeekerHistory { @@ -36,8 +36,12 @@ public sealed record SeekerHistory public int SeasonNumber { get; set; } /// - /// The cycle ID. All searches in the same cycle share a CycleId. - /// When all items have been searched, a new CycleId is generated to start a fresh cycle. + /// For Sonarr episode searches, the episode that was searched. 0 for a season or movie unit. + /// + public long EpisodeId { get; set; } + + /// + /// The last cycle this unit was searched in /// public Guid CycleId { get; set; } @@ -52,10 +56,20 @@ public sealed record SeekerHistory public string ItemTitle { get; set; } = string.Empty; /// - /// Running count of how many times this item has been searched + /// Lifetime count of searches for this unit /// public int SearchCount { get; set; } = 1; + /// + /// Lifetime count of searches for this unit that grabbed a release + /// + public int Grabs { get; set; } + + /// + /// Result of the last search. Null until that search finishes. + /// + public SeekerSearchOutcome? LastOutcome { get; set; } + /// /// Whether this history entry was created during a dry run ///