added persisted seeker cycles and search units

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent a9ff7283bd
commit 21eebadc28
14 files changed
+3192 -179

No files matched your search

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerCycleCompletionReason
{
Completed,
}
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Domain.Enums;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SeekerSearchOutcome
{
Grabbed,
NothingGrabbed,
Failed,
}
@@ -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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
QueueRecord[] queuedRecords =
[
new() { Id = 1, Title = "Movie 3", DownloadId = "hash1", Protocol = "torrent", SizeLeft = 1000, MovieId = 3, TrackedDownloadState = "downloading" }
];
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns(queuedRecords.ToList());
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockArrClient.Received(1).SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_sonarrClient
.StreamAllSeriesAsync(sonarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableSeries>([
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<ArrInstance>(), Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
List<SearchableMovie> 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<CancellationToken>())
.Returns(ToAsyncEnumerable(movies));
StubRadarrWanted(radarrInstance, movies);
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<SearchItem>());
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<SearchItem>());
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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([]));
StubRadarrWanted(radarrInstance, []);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: the empty cycle stays open
await mockArrClient.DidNotReceive().SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.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
}
@@ -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<SeekerHistory> 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<SeekerHistory> 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<long, DateTimeOffset> searchHistory,
Guid cycleId,
SeekerCycle cycle,
bool isDryRun,
HashSet<long> 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<SearchableMovie> 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<long, DateTimeOffset>();
}
@@ -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<SeekerProcessResult> ProcessSonarrAsync(
@@ -771,7 +742,7 @@ public sealed class Seeker : IHandler
SeekerInstanceConfig instanceConfig,
Dictionary<long, DateTimeOffset> seriesSearchHistory,
List<SeekerHistory> 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<long, DateTimeOffset>();
remaining = available;
cycle.RemainingUnits = remaining.Count;
}
Dictionary<long, List<int>> 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<long> searchedIds,
List<string>? 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
}
/// <summary>
/// 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.
/// </summary>
private async Task CleanupOldCycleHistoryAsync(ArrInstance arrInstance, Guid currentCycleId)
private async Task<SeekerCycle> 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<SeekerCycle> 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<DateTimeOffset> 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;
}
/// <summary>
/// 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.
/// </summary>
private async Task<SeekerCycle?> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
@@ -1,3 +1,5 @@
using Cleanuparr.Persistence.Models.State;
namespace Cleanuparr.Infrastructure.Features.Seeker;
/// <summary>
@@ -10,7 +12,6 @@ internal sealed record SeekerProcessResult
/// <summary>
/// Cycle the candidates belong to.
/// A dry run gets a simulated cycle that never reaches the config.
/// </summary>
public required Guid CycleId { get; init; }
public required SeekerCycle Cycle { get; init; }
}
@@ -0,0 +1,837 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid?>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<string>("CleanReason")
.HasColumnType("text")
.HasColumnName("clean_reason");
b.Property<string>("CleanedCategory")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("cleaned_category");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<Guid?>("CycleId")
.HasColumnType("uuid")
.HasColumnName("cycle_id");
b.Property<string>("DeleteReason")
.HasColumnType("text")
.HasColumnName("delete_reason");
b.Property<Guid?>("DownloadClientId")
.HasColumnType("uuid")
.HasColumnName("download_client_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("event_type");
b.PrimitiveCollection<List<string>>("FailedImportReasons")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("failed_import_reasons");
b.PrimitiveCollection<List<string>>("GrabbedItems")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("grabbed_items");
b.Property<bool?>("IsCategoryTag")
.HasColumnType("boolean")
.HasColumnName("is_category_tag");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("uuid")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("message");
b.Property<string>("NewCategory")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("new_category");
b.Property<string>("OldCategory")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("old_category");
b.Property<bool?>("RemoveFromClient")
.HasColumnType("boolean")
.HasColumnName("remove_from_client");
b.Property<string>("SearchReason")
.HasColumnType("text")
.HasColumnName("search_reason");
b.Property<string>("SearchStatus")
.HasColumnType("text")
.HasColumnName("search_status");
b.Property<string>("SearchType")
.HasColumnType("text")
.HasColumnName("search_type");
b.Property<double?>("SeedRatio")
.HasColumnType("double precision")
.HasColumnName("seed_ratio");
b.Property<double?>("SeedingTimeHours")
.HasColumnType("double precision")
.HasColumnName("seeding_time_hours");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("integer")
.HasColumnName("strike_count");
b.Property<Guid?>("StrikeId")
.HasColumnType("uuid")
.HasColumnName("strike_id");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("text")
.HasColumnName("download_client_type");
b.Property<string>("InstanceType")
.HasColumnType("text")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("instance_url");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<bool>("IsResolved")
.HasColumnType("boolean")
.HasColumnName("is_resolved");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("character varying(500)")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("uuid")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("message");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("integer")
.HasColumnName("strike_count");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone")
.HasColumnName("timestamp");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<int>("CurrentScore")
.HasColumnType("integer")
.HasColumnName("current_score");
b.Property<int>("CutoffScore")
.HasColumnType("integer")
.HasColumnName("cutoff_score");
b.Property<long>("EpisodeId")
.HasColumnType("bigint")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("bigint")
.HasColumnName("external_item_id");
b.Property<long>("FileId")
.HasColumnType("bigint")
.HasColumnName("file_id");
b.Property<bool>("IsMonitored")
.HasColumnType("boolean")
.HasColumnName("is_monitored");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<DateTimeOffset?>("LastUpgradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_upgraded_at");
b.Property<string>("QualityProfileName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("quality_profile_name");
b.Property<int?>("SeasonNumber")
.HasColumnType("integer")
.HasColumnName("season_number");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<int>("CutoffScore")
.HasColumnType("integer")
.HasColumnName("cutoff_score");
b.Property<long>("EpisodeId")
.HasColumnType("bigint")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("bigint")
.HasColumnName("external_item_id");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("Score")
.HasColumnType("integer")
.HasColumnName("score");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("DownloadId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("boolean")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("boolean")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("boolean")
.HasColumnName("is_returning");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<string>("Status")
.HasColumnType("text")
.HasColumnName("status");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_job_runs");
b.HasIndex("StartedAt")
.IsDescending()
.HasDatabaseName("ix_job_runs_started_at");
b.HasIndex("Type")
.HasDatabaseName("ix_job_runs_type");
b.ToTable("job_runs", "events");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long>("ItemId")
.HasColumnType("bigint")
.HasColumnName("item_id");
b.Property<string>("SearchType")
.HasColumnType("text")
.HasColumnName("search_type");
b.Property<long?>("SeriesId")
.HasColumnType("bigint")
.HasColumnName("series_id");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<long>("CommandId")
.HasColumnType("bigint")
.HasColumnName("command_id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<long>("EpisodeId")
.HasColumnType("bigint")
.HasColumnName("episode_id");
b.Property<Guid>("EventId")
.HasColumnType("uuid")
.HasColumnName("event_id");
b.Property<long>("ExternalItemId")
.HasColumnType("bigint")
.HasColumnName("external_item_id");
b.Property<string>("ItemTitle")
.IsRequired()
.HasColumnType("text")
.HasColumnName("item_title");
b.Property<int>("SeasonNumber")
.HasColumnType("integer")
.HasColumnName("season_number");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("CompletionReason")
.HasColumnType("text")
.HasColumnName("completion_reason");
b.Property<int>("EligibleAtStart")
.HasColumnType("integer")
.HasColumnName("eligible_at_start");
b.Property<int>("EligibleNow")
.HasColumnType("integer")
.HasColumnName("eligible_now");
b.Property<int>("GrabbedUnits")
.HasColumnType("integer")
.HasColumnName("grabbed_units");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<int>("RemainingUnits")
.HasColumnType("integer")
.HasColumnName("remaining_units");
b.Property<int>("SearchedUnits")
.HasColumnType("integer")
.HasColumnName("searched_units");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<Guid>("CycleId")
.HasColumnType("uuid")
.HasColumnName("cycle_id");
b.Property<long>("EpisodeId")
.HasColumnType("bigint")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("bigint")
.HasColumnName("external_item_id");
b.Property<int>("Grabs")
.HasColumnType("integer")
.HasColumnName("grabs");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<string>("ItemTitle")
.IsRequired()
.HasColumnType("text")
.HasColumnName("item_title");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("item_type");
b.Property<string>("LastOutcome")
.HasColumnType("text")
.HasColumnName("last_outcome");
b.Property<DateTimeOffset>("LastSearchedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_searched_at");
b.Property<int>("SearchCount")
.HasColumnType("integer")
.HasColumnName("search_count");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("DownloadItemId")
.HasColumnType("uuid")
.HasColumnName("download_item_id");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<Guid>("JobRunId")
.HasColumnType("uuid")
.HasColumnName("job_run_id");
b.Property<long?>("LastDownloadedBytes")
.HasColumnType("bigint")
.HasColumnName("last_downloaded_bytes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_strikes");
b.HasIndex("CreatedAt")
.HasDatabaseName("ix_strikes_created_at");
b.HasIndex("JobRunId")
.HasDatabaseName("ix_strikes_job_run_id");
b.HasIndex("DownloadItemId", "Type")
.HasDatabaseName("ix_strikes_download_item_id_type");
b.ToTable("strikes", "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
}
}
}
@@ -0,0 +1,152 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Events
{
/// <inheritdoc />
public partial class AddSeekerCycles : Migration
{
/// <inheritdoc />
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<long>(
name: "episode_id",
schema: "events",
table: "seeker_history",
type: "bigint",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<int>(
name: "grabs",
schema: "events",
table: "seeker_history",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
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<Guid>(type: "uuid", nullable: false),
arr_instance_id = table.Column<Guid>(type: "uuid", nullable: false),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
completion_reason = table.Column<string>(type: "text", nullable: true),
eligible_at_start = table.Column<int>(type: "integer", nullable: false),
eligible_now = table.Column<int>(type: "integer", nullable: false),
remaining_units = table.Column<int>(type: "integer", nullable: false),
searched_units = table.Column<int>(type: "integer", nullable: false),
grabbed_units = table.Column<int>(type: "integer", nullable: false),
is_dry_run = table.Column<bool>(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");
}
/// <inheritdoc />
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);
}
}
}
@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<string>("CompletionReason")
.HasColumnType("text")
.HasColumnName("completion_reason");
b.Property<int>("EligibleAtStart")
.HasColumnType("integer")
.HasColumnName("eligible_at_start");
b.Property<int>("EligibleNow")
.HasColumnType("integer")
.HasColumnName("eligible_now");
b.Property<int>("GrabbedUnits")
.HasColumnType("integer")
.HasColumnName("grabbed_units");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
b.Property<int>("RemainingUnits")
.HasColumnType("integer")
.HasColumnName("remaining_units");
b.Property<int>("SearchedUnits")
.HasColumnType("integer")
.HasColumnName("searched_units");
b.Property<DateTimeOffset>("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<Guid>("Id")
@@ -614,10 +670,18 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events
.HasColumnType("uuid")
.HasColumnName("cycle_id");
b.Property<long>("EpisodeId")
.HasColumnType("bigint")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("bigint")
.HasColumnName("external_item_id");
b.Property<int>("Grabs")
.HasColumnType("integer")
.HasColumnName("grabs");
b.Property<bool>("IsDryRun")
.HasColumnType("boolean")
.HasColumnName("is_dry_run");
@@ -632,6 +696,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events
.HasColumnType("text")
.HasColumnName("item_type");
b.Property<string>("LastOutcome")
.HasColumnType("text")
.HasColumnName("last_outcome");
b.Property<DateTimeOffset>("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");
@@ -0,0 +1,840 @@
// <auto-generated />
using System;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
[DbContext(typeof(EventsContext))]
[Migration("20261017173918_AddSeekerCycles")]
partial class AddSeekerCycles
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid?>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<string>("CleanReason")
.HasColumnType("TEXT")
.HasColumnName("clean_reason");
b.Property<string>("CleanedCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("cleaned_category");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<Guid?>("CycleId")
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<string>("DeleteReason")
.HasColumnType("TEXT")
.HasColumnName("delete_reason");
b.Property<Guid?>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.PrimitiveCollection<string>("FailedImportReasons")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_reasons");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<bool?>("IsCategoryTag")
.HasColumnType("INTEGER")
.HasColumnName("is_category_tag");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("NewCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("new_category");
b.Property<string>("OldCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("old_category");
b.Property<bool?>("RemoveFromClient")
.HasColumnType("INTEGER")
.HasColumnName("remove_from_client");
b.Property<string>("SearchReason")
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("SearchStatus")
.HasColumnType("TEXT")
.HasColumnName("search_status");
b.Property<string>("SearchType")
.HasColumnType("TEXT")
.HasColumnName("search_type");
b.Property<double?>("SeedRatio")
.HasColumnType("REAL")
.HasColumnName("seed_ratio");
b.Property<double?>("SeedingTimeHours")
.HasColumnType("REAL")
.HasColumnName("seeding_time_hours");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<Guid?>("StrikeId")
.HasColumnType("TEXT")
.HasColumnName("strike_id");
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<Guid?>("TrackingId")
.HasColumnType("TEXT")
.HasColumnName("tracking_id");
b.HasKey("Id")
.HasName("pk_events");
b.HasIndex("ArrInstanceId")
.HasDatabaseName("ix_events_arr_instance_id");
b.HasIndex("CycleId")
.HasDatabaseName("ix_events_cycle_id");
b.HasIndex("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("ResolvedAt")
.HasColumnType("TEXT")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<int>("CurrentScore")
.HasColumnType("INTEGER")
.HasColumnName("current_score");
b.Property<int>("CutoffScore")
.HasColumnType("INTEGER")
.HasColumnName("cutoff_score");
b.Property<long>("EpisodeId")
.HasColumnType("INTEGER")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("INTEGER")
.HasColumnName("external_item_id");
b.Property<long>("FileId")
.HasColumnType("INTEGER")
.HasColumnName("file_id");
b.Property<bool>("IsMonitored")
.HasColumnType("INTEGER")
.HasColumnName("is_monitored");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<string>("LastSyncedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_synced_at");
b.Property<string>("LastUpgradedAt")
.HasColumnType("TEXT")
.HasColumnName("last_upgraded_at");
b.Property<string>("QualityProfileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("quality_profile_name");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER")
.HasColumnName("season_number");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<int>("CutoffScore")
.HasColumnType("INTEGER")
.HasColumnName("cutoff_score");
b.Property<long>("EpisodeId")
.HasColumnType("INTEGER")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("INTEGER")
.HasColumnName("external_item_id");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<string>("RecordedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("recorded_at");
b.Property<int>("Score")
.HasColumnType("INTEGER")
.HasColumnName("score");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("INTEGER")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("INTEGER")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("INTEGER")
.HasColumnName("is_returning");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_download_items");
b.HasIndex("DownloadId")
.IsUnique()
.HasDatabaseName("ix_download_items_download_id");
b.ToTable("download_items", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<string>("StartedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_job_runs");
b.HasIndex("StartedAt")
.IsDescending()
.HasDatabaseName("ix_job_runs_started_at");
b.HasIndex("Type")
.HasDatabaseName("ix_job_runs_type");
b.ToTable("job_runs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<long>("ItemId")
.HasColumnType("INTEGER")
.HasColumnName("item_id");
b.Property<string>("SearchType")
.HasColumnType("TEXT")
.HasColumnName("search_type");
b.Property<long?>("SeriesId")
.HasColumnType("INTEGER")
.HasColumnName("series_id");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<long>("CommandId")
.HasColumnType("INTEGER")
.HasColumnName("command_id");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<long>("EpisodeId")
.HasColumnType("INTEGER")
.HasColumnName("episode_id");
b.Property<Guid>("EventId")
.HasColumnType("TEXT")
.HasColumnName("event_id");
b.Property<long>("ExternalItemId")
.HasColumnType("INTEGER")
.HasColumnName("external_item_id");
b.Property<string>("ItemTitle")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER")
.HasColumnName("season_number");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<string>("CompletionReason")
.HasColumnType("TEXT")
.HasColumnName("completion_reason");
b.Property<int>("EligibleAtStart")
.HasColumnType("INTEGER")
.HasColumnName("eligible_at_start");
b.Property<int>("EligibleNow")
.HasColumnType("INTEGER")
.HasColumnName("eligible_now");
b.Property<int>("GrabbedUnits")
.HasColumnType("INTEGER")
.HasColumnName("grabbed_units");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<int>("RemainingUnits")
.HasColumnType("INTEGER")
.HasColumnName("remaining_units");
b.Property<int>("SearchedUnits")
.HasColumnType("INTEGER")
.HasColumnName("searched_units");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<Guid>("CycleId")
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<long>("EpisodeId")
.HasColumnType("INTEGER")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("INTEGER")
.HasColumnName("external_item_id");
b.Property<int>("Grabs")
.HasColumnType("INTEGER")
.HasColumnName("grabs");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<string>("ItemTitle")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<string>("ItemType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<string>("LastOutcome")
.HasColumnType("TEXT")
.HasColumnName("last_outcome");
b.Property<string>("LastSearchedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_searched_at");
b.Property<int>("SearchCount")
.HasColumnType("INTEGER")
.HasColumnName("search_count");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<Guid>("DownloadItemId")
.HasColumnType("TEXT")
.HasColumnName("download_item_id");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<Guid>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<long?>("LastDownloadedBytes")
.HasColumnType("INTEGER")
.HasColumnName("last_downloaded_bytes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_strikes");
b.HasIndex("CreatedAt")
.HasDatabaseName("ix_strikes_created_at");
b.HasIndex("JobRunId")
.HasDatabaseName("ix_strikes_job_run_id");
b.HasIndex("DownloadItemId", "Type")
.HasDatabaseName("ix_strikes_download_item_id_type");
b.ToTable("strikes", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
.WithMany("Events")
.HasForeignKey("JobRunId")
.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
}
}
}
@@ -0,0 +1,139 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class AddSeekerCycles : Migration
{
/// <inheritdoc />
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<long>(
name: "episode_id",
table: "seeker_history",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<int>(
name: "grabs",
table: "seeker_history",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "last_outcome",
table: "seeker_history",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "seeker_cycles",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
started_at = table.Column<string>(type: "TEXT", nullable: false),
completed_at = table.Column<string>(type: "TEXT", nullable: true),
completion_reason = table.Column<string>(type: "TEXT", nullable: true),
eligible_at_start = table.Column<int>(type: "INTEGER", nullable: false),
eligible_now = table.Column<int>(type: "INTEGER", nullable: false),
remaining_units = table.Column<int>(type: "INTEGER", nullable: false),
searched_units = table.Column<int>(type: "INTEGER", nullable: false),
grabbed_units = table.Column<int>(type: "INTEGER", nullable: false),
is_dry_run = table.Column<bool>(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");
}
/// <inheritdoc />
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);
}
}
}
@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<string>("CompletionReason")
.HasColumnType("TEXT")
.HasColumnName("completion_reason");
b.Property<int>("EligibleAtStart")
.HasColumnType("INTEGER")
.HasColumnName("eligible_at_start");
b.Property<int>("EligibleNow")
.HasColumnType("INTEGER")
.HasColumnName("eligible_now");
b.Property<int>("GrabbedUnits")
.HasColumnType("INTEGER")
.HasColumnName("grabbed_units");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<int>("RemainingUnits")
.HasColumnType("INTEGER")
.HasColumnName("remaining_units");
b.Property<int>("SearchedUnits")
.HasColumnType("INTEGER")
.HasColumnName("searched_units");
b.Property<string>("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<Guid>("Id")
@@ -614,10 +671,18 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<long>("EpisodeId")
.HasColumnType("INTEGER")
.HasColumnName("episode_id");
b.Property<long>("ExternalItemId")
.HasColumnType("INTEGER")
.HasColumnName("external_item_id");
b.Property<int>("Grabs")
.HasColumnType("INTEGER")
.HasColumnName("grabs");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
@@ -632,6 +697,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<string>("LastOutcome")
.HasColumnType("TEXT")
.HasColumnName("last_outcome");
b.Property<string>("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);
});
@@ -26,6 +26,8 @@ public class EventsContext : DbContext
public DbSet<SeekerHistory> SeekerHistory { get; set; }
public DbSet<SeekerCycle> SeekerCycles { get; set; }
public DbSet<SearchQueueItem> SearchQueue { get; set; }
public DbSet<CustomFormatScoreEntry> CustomFormatScoreEntries { get; set; }
@@ -123,7 +125,12 @@ public class EventsContext : DbContext
modelBuilder.Entity<SeekerHistory>(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<SeekerCycle>(entity =>
{
entity.HasIndex(s => s.ArrInstanceId);
});
modelBuilder.Entity<SearchQueueItem>(entity =>
@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.State;
/// <summary>
/// One Seeker cycle of an arr instance: every eligible unit searched once.
/// </summary>
public sealed record SeekerCycle
{
/// <summary>
/// The cycle id, shared with the search events and units searched in it
/// </summary>
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The arr instance this cycle belongs to
/// </summary>
public Guid ArrInstanceId { get; set; }
/// <summary>
/// When the cycle opened
/// </summary>
public DateTimeOffset StartedAt { get; set; }
/// <summary>
/// When the last eligible unit got searched. Null while the cycle runs.
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// Why the cycle ended. Null while the cycle runs.
/// </summary>
public SeekerCycleCompletionReason? CompletionReason { get; set; }
/// <summary>
/// Eligible units when the cycle opened
/// </summary>
public int EligibleAtStart { get; set; }
/// <summary>
/// Eligible units on the latest run
/// </summary>
public int EligibleNow { get; set; }
/// <summary>
/// Eligible units on the latest run that this cycle has not searched and that are not in the queue
/// </summary>
public int RemainingUnits { get; set; }
/// <summary>
/// Searches triggered in this cycle
/// </summary>
public int SearchedUnits { get; set; }
/// <summary>
/// Searches in this cycle that grabbed a release
/// </summary>
public int GrabbedUnits { get; set; }
/// <summary>
/// Whether the cycle ran in dry run mode
/// </summary>
public bool IsDryRun { get; set; }
}
@@ -6,8 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.Arr;
namespace Cleanuparr.Persistence.Models.State;
/// <summary>
/// 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.
/// </summary>
public sealed record SeekerHistory
{
@@ -36,8 +36,12 @@ public sealed record SeekerHistory
public int SeasonNumber { get; set; }
/// <summary>
/// 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.
/// </summary>
public long EpisodeId { get; set; }
/// <summary>
/// The last cycle this unit was searched in
/// </summary>
public Guid CycleId { get; set; }
@@ -52,10 +56,20 @@ public sealed record SeekerHistory
public string ItemTitle { get; set; } = string.Empty;
/// <summary>
/// Running count of how many times this item has been searched
/// Lifetime count of searches for this unit
/// </summary>
public int SearchCount { get; set; } = 1;
/// <summary>
/// Lifetime count of searches for this unit that grabbed a release
/// </summary>
public int Grabs { get; set; }
/// <summary>
/// Result of the last search. Null until that search finishes.
/// </summary>
public SeekerSearchOutcome? LastOutcome { get; set; }
/// <summary>
/// Whether this history entry was created during a dry run
/// </summary>