mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
added persisted seeker cycles and search units
This commit is contained in:
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; }
|
||||
}
|
||||
+837
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-1
@@ -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");
|
||||
|
||||
|
||||
+840
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
-2
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user