mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added recent grab window to seeker instances
This commit is contained in:
1 parent
d079688c6f
commit
330130d7bc
22 files changed
+4780
-4
No files matched your search
@@ -56,6 +56,7 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
instance.RecentGrabWindowHours.ShouldBe(24);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,6 +97,25 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
response.Instances.ShouldHaveSingleItem().IncludeTags.ShouldBe(["wanted", "4k"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithRecentGrabWindowHours_ReturnsStoredValue()
|
||||
{
|
||||
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 48
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
IActionResult result = await _controller.GetSeekerConfig();
|
||||
OkObjectResult okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
SeekerConfigResponse response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.ShouldHaveSingleItem().RecentGrabWindowHours.ShouldBe(48);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
@@ -378,7 +398,8 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
SkipTags = ["old-tag"],
|
||||
IncludeTags = ["old-include"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
MinCycleTimeDays = 5,
|
||||
RecentGrabWindowHours = 12
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
@@ -398,7 +419,8 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
IncludeTags = ["new-include"],
|
||||
ActiveDownloadLimit = 5,
|
||||
IgnoreStruckDownloads = true,
|
||||
MinCycleTimeDays = 14
|
||||
MinCycleTimeDays = 14,
|
||||
RecentGrabWindowHours = 36
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
@@ -408,7 +430,8 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
SkipTags = ["sonarr-tag"],
|
||||
IncludeTags = ["sonarr-include"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
MinCycleTimeDays = 7,
|
||||
RecentGrabWindowHours = 6
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -425,12 +448,14 @@ public class SeekerConfigControllerTests : IDisposable
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.IgnoreStruckDownloads.ShouldBeTrue();
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
radarrConfig.RecentGrabWindowHours.ShouldBe(36);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
sonarrConfig.IncludeTags.ShouldBe(["sonarr-include"]);
|
||||
sonarrConfig.IgnoreStruckDownloads.ShouldBeFalse();
|
||||
sonarrConfig.RecentGrabWindowHours.ShouldBe(6);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
+2
@@ -21,4 +21,6 @@ public sealed record UpdateSeekerInstanceConfigRequest
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
|
||||
public int RecentGrabWindowHours { get; init; } = 24;
|
||||
}
|
||||
+2
@@ -31,4 +31,6 @@ public sealed record SeekerInstanceConfigResponse
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
|
||||
public int RecentGrabWindowHours { get; init; }
|
||||
}
|
||||
@@ -70,6 +70,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
MonitoredOnly = seekerConfig?.MonitoredOnly ?? true,
|
||||
UseCutoff = seekerConfig?.UseCutoff ?? false,
|
||||
UseCustomFormatScore = seekerConfig?.UseCustomFormatScore ?? false,
|
||||
RecentGrabWindowHours = seekerConfig?.RecentGrabWindowHours ?? 24,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
@@ -134,6 +135,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
existing.MonitoredOnly = instanceReq.MonitoredOnly;
|
||||
existing.UseCutoff = instanceReq.UseCutoff;
|
||||
existing.UseCustomFormatScore = instanceReq.UseCustomFormatScore;
|
||||
existing.RecentGrabWindowHours = instanceReq.RecentGrabWindowHours;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -149,6 +151,7 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
MonitoredOnly = instanceReq.MonitoredOnly,
|
||||
UseCutoff = instanceReq.UseCutoff,
|
||||
UseCustomFormatScore = instanceReq.UseCustomFormatScore,
|
||||
RecentGrabWindowHours = instanceReq.RecentGrabWindowHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.History;
|
||||
|
||||
/// <summary>
|
||||
/// The episode data embedded in an *arr history row.
|
||||
/// </summary>
|
||||
public sealed record ArrHistoryEpisode
|
||||
{
|
||||
/// <summary>
|
||||
/// The season number the episode belongs to.
|
||||
/// </summary>
|
||||
public long SeasonNumber { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr.History;
|
||||
|
||||
/// <summary>
|
||||
/// One row of an *arr application's history.
|
||||
/// </summary>
|
||||
public sealed record ArrHistoryRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// The movie this row is about. 0 when the row is not about a movie.
|
||||
/// </summary>
|
||||
public long MovieId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The series this row is about. 0 when the row is not about a series.
|
||||
/// </summary>
|
||||
public long SeriesId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The episode's own data, present only when the query asked for it.
|
||||
/// </summary>
|
||||
public ArrHistoryEpisode? Episode { get; init; }
|
||||
}
|
||||
@@ -538,6 +538,54 @@ public class RadarrClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentlyGrabbedMovieIdsAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedMovieIdsAsync_BuildsExpectedUri()
|
||||
{
|
||||
// Arrange
|
||||
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<object>())));
|
||||
DateTimeOffset since = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
await _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, since);
|
||||
|
||||
// Assert
|
||||
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
|
||||
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/history/since");
|
||||
request.RequestUri.Query.ShouldBe("?date=2026-01-01T00%3A00%3A00.0000000Z&eventType=1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedMovieIdsAsync_DropsZeroMovieIds()
|
||||
{
|
||||
// Arrange
|
||||
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
|
||||
{
|
||||
new { movieId = 1 },
|
||||
new { movieId = 0 },
|
||||
})));
|
||||
|
||||
// Act
|
||||
HashSet<long> ids = await _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
ids.ShouldBe([1L]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedMovieIdsAsync_TheArrRefused_Throws()
|
||||
{
|
||||
// Arrange
|
||||
_httpMessageHandler.SetupResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act, Assert
|
||||
await Should.ThrowAsync<HttpRequestException>(
|
||||
() => _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static HttpResponseMessage JsonResponse<T>(T body) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -696,6 +696,60 @@ public class SonarrClientTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentlyGrabbedSeasonsAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedSeasonsAsync_BuildsExpectedUri()
|
||||
{
|
||||
// Arrange
|
||||
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<object>())));
|
||||
DateTimeOffset since = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Act
|
||||
await _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, since);
|
||||
|
||||
// Assert
|
||||
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
|
||||
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/history/since");
|
||||
request.RequestUri.Query.ShouldBe("?date=2026-01-01T00%3A00%3A00.0000000Z&eventType=1&includeEpisode=true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedSeasonsAsync_DropsRowsWithoutSeriesOrEpisode()
|
||||
{
|
||||
// Arrange
|
||||
const string body = """
|
||||
[
|
||||
{ "seriesId": 10, "episode": { "seasonNumber": 1 } },
|
||||
{ "seriesId": 0, "episode": { "seasonNumber": 2 } },
|
||||
{ "seriesId": 11, "episode": null }
|
||||
]
|
||||
""";
|
||||
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
}));
|
||||
|
||||
// Act
|
||||
HashSet<(long SeriesId, long SeasonNumber)> seasons = await _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
seasons.ShouldBe([(10L, 1L)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentlyGrabbedSeasonsAsync_TheArrRefused_Throws()
|
||||
{
|
||||
// Arrange
|
||||
_httpMessageHandler.SetupResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act, Assert
|
||||
await Should.ThrowAsync<HttpRequestException>(
|
||||
() => _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static HttpResponseMessage JsonResponse<T>(T body) => new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
|
||||
|
||||
@@ -60,6 +60,14 @@ public class SeekerTests : IDisposable
|
||||
.GetAllTagsAsync(Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
// Default: no recently grabbed movies/seasons
|
||||
_radarrClient
|
||||
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>())
|
||||
.Returns([]);
|
||||
_sonarrClient
|
||||
.GetRecentlyGrabbedSeasonsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>())
|
||||
.Returns([]);
|
||||
|
||||
// Default: PublishSearchTriggered returns a Guid
|
||||
_fixture.EventPublisher
|
||||
.PublishSearchTriggered(
|
||||
@@ -5541,4 +5549,288 @@ public class SeekerTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Recent Grab Window
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_ExcludesRecentlyGrabbedMovie()
|
||||
{
|
||||
// Arrange: movie 2 was grabbed recently, movie 1 is still eligible
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 24
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
_radarrClient
|
||||
.GetRecentlyGrabbedMovieIdsAsync(radarrInstance, Arg.Any<DateTimeOffset>())
|
||||
.Returns([2L]);
|
||||
|
||||
_radarrClient
|
||||
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
ToAsyncEnumerable<SearchableMovie>([
|
||||
new SearchableMovie { Id = 1, Title = "Movie 1", Status = "released", Monitored = true, Tags = [] },
|
||||
new SearchableMovie { Id = 2, Title = "Movie 2", Status = "released", Monitored = true, Tags = [] }
|
||||
]));
|
||||
|
||||
HashSet<SearchItem>? capturedSearchItems = null;
|
||||
mockArrClient
|
||||
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
capturedSearchItems = [ci.ArgAt<SearchItem>(1)];
|
||||
return 100L;
|
||||
});
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Radarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: only movie 1 was searched
|
||||
await mockArrClient.Received(1)
|
||||
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
|
||||
|
||||
capturedSearchItems.ShouldNotBeNull();
|
||||
capturedSearchItems.ShouldNotContain(item => item.Id == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Sonarr_ExcludesSeasonWithRecentlyGrabbedEpisode()
|
||||
{
|
||||
// Arrange: season 1 of series 10 had an episode grabbed recently, season 2 stays eligible
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 24
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
_sonarrClient
|
||||
.GetRecentlyGrabbedSeasonsAsync(sonarrInstance, Arg.Any<DateTimeOffset>())
|
||||
.Returns([(10L, 1L)]);
|
||||
|
||||
_sonarrClient
|
||||
.StreamAllSeriesAsync(Arg.Any<ArrInstance>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
ToAsyncEnumerable<SearchableSeries>([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 20, EpisodeFileCount = 10 } }
|
||||
]));
|
||||
|
||||
DateTime pastDate = _fixture.TimeProvider.GetUtcNow().UtcDateTime.AddDays(-30);
|
||||
_sonarrClient
|
||||
.GetEpisodesAsync(Arg.Any<ArrInstance>(), 10)
|
||||
.Returns(
|
||||
[
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false },
|
||||
new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = false }
|
||||
]);
|
||||
|
||||
SeriesSearchItem? capturedSearchItem = null;
|
||||
mockArrClient
|
||||
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
capturedSearchItem = (SeriesSearchItem)ci.ArgAt<SearchItem>(1);
|
||||
return 100L;
|
||||
});
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: only season 2 was searched
|
||||
await mockArrClient.Received(1)
|
||||
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
|
||||
|
||||
capturedSearchItem.ShouldNotBeNull();
|
||||
capturedSearchItem!.Id.ShouldBe(2L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecentGrabWindowZero_DoesNotReadGrabHistory()
|
||||
{
|
||||
// Arrange: window disabled, history read should be skipped entirely
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 0
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
_radarrClient
|
||||
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
|
||||
.Returns(ToAsyncEnumerable<SearchableMovie>([]));
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Radarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: history endpoint was never queried
|
||||
await _radarrClient.DidNotReceive()
|
||||
.GetRecentlyGrabbedMovieIdsAsync(Arg.Any<ArrInstance>(), Arg.Any<DateTimeOffset>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_GrabHistoryReadThrows_SkipsInstanceWithoutSearching()
|
||||
{
|
||||
// Arrange: the history read fails, so the instance must be skipped entirely
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 24
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
_radarrClient
|
||||
.GetRecentlyGrabbedMovieIdsAsync(radarrInstance, Arg.Any<DateTimeOffset>())
|
||||
.ThrowsAsync(new HttpRequestException("boom"));
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Radarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: no search was attempted for the instance
|
||||
await mockArrClient.DidNotReceive()
|
||||
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecentGrabWindow_ReadsHistorySinceNowMinusWindowHours()
|
||||
{
|
||||
// Arrange: the window is read relative to the time provider's current time
|
||||
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
RecentGrabWindowHours = 12
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
await _fixture.EventsContext.SaveChangesAsync();
|
||||
|
||||
IArrClient mockArrClient = Substitute.For<IArrClient>();
|
||||
|
||||
_fixture.ArrQueueReader
|
||||
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
|
||||
.Returns([]);
|
||||
|
||||
_radarrClient
|
||||
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
|
||||
.Returns(ToAsyncEnumerable<SearchableMovie>([]));
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Radarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
DateTimeOffset expectedSince = _fixture.TimeProvider.GetUtcNow().AddHours(-12);
|
||||
|
||||
SeekerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await _radarrClient.Received(1)
|
||||
.GetRecentlyGrabbedMovieIdsAsync(radarrInstance, Arg.Is<DateTimeOffset>(d => d == expectedSince));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -26,6 +26,11 @@ public abstract class ArrClient : IArrClient
|
||||
/// </summary>
|
||||
private const int DownloadFolderImportedEvent = 3;
|
||||
|
||||
/// <summary>
|
||||
/// What every arr calls grabbing a release, as its history event id.
|
||||
/// </summary>
|
||||
private const int GrabbedEvent = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Page size used when paging through an *arr instance's queue.
|
||||
/// </summary>
|
||||
@@ -271,6 +276,37 @@ public abstract class ArrClient : IArrClient
|
||||
return history?.TotalRecords ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the grabbed releases an arr recorded in its history since a given time.
|
||||
/// </summary>
|
||||
/// <param name="arrInstance">The arr instance to query.</param>
|
||||
/// <param name="since">Only rows at or after this time are returned.</param>
|
||||
/// <param name="extraQuery">Additional query parameters, appended as-is (e.g. "&includeEpisode=true").</param>
|
||||
protected async Task<List<ArrHistoryRecord>> GetGrabHistorySinceAsync(ArrInstance arrInstance, DateTimeOffset since, string extraQuery)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/{ApiVersion}/history/since";
|
||||
string date = Uri.EscapeDataString(since.UtcDateTime.ToString("o"));
|
||||
uriBuilder.Query = $"date={date}&eventType={GrabbedEvent}{extraQuery}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
try
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logger.LogError("grab history failed | {Uri}", uriBuilder.Uri);
|
||||
throw;
|
||||
}
|
||||
|
||||
return await DeserializeStreamAsync<List<ArrHistoryRecord>>(response) ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual ManualImportFile? MapCandidate(QueueRecord record, ManualImportCandidate candidate) => null;
|
||||
|
||||
|
||||
@@ -19,4 +19,9 @@ public interface IRadarrClient : IArrClient
|
||||
/// Fetches custom format scores for movie files in batches
|
||||
/// </summary>
|
||||
Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the ids of movies Radarr grabbed since the given time.
|
||||
/// </summary>
|
||||
Task<HashSet<long>> GetRecentlyGrabbedMovieIdsAsync(ArrInstance arrInstance, DateTimeOffset since);
|
||||
}
|
||||
@@ -29,4 +29,9 @@ public interface ISonarrClient : IArrClient
|
||||
/// Fetches custom format scores for episode files in batches
|
||||
/// </summary>
|
||||
Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the series/season pairs Sonarr grabbed an episode for since the given time.
|
||||
/// </summary>
|
||||
Task<HashSet<(long SeriesId, long SeasonNumber)>> GetRecentlyGrabbedSeasonsAsync(ArrInstance arrInstance, DateTimeOffset since);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.History;
|
||||
using Cleanuparr.Domain.Entities.Arr.ManualImport;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Radarr;
|
||||
@@ -222,6 +223,17 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
return scores;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HashSet<long>> GetRecentlyGrabbedMovieIdsAsync(ArrInstance arrInstance, DateTimeOffset since)
|
||||
{
|
||||
List<ArrHistoryRecord> records = await GetGrabHistorySinceAsync(arrInstance, since, string.Empty);
|
||||
|
||||
return records
|
||||
.Where(record => record.MovieId > 0)
|
||||
.Select(record => record.MovieId)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.History;
|
||||
using Cleanuparr.Domain.Entities.Arr.ManualImport;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Sonarr;
|
||||
@@ -338,6 +339,17 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return scores;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<HashSet<(long SeriesId, long SeasonNumber)>> GetRecentlyGrabbedSeasonsAsync(ArrInstance arrInstance, DateTimeOffset since)
|
||||
{
|
||||
List<ArrHistoryRecord> records = await GetGrabHistorySinceAsync(arrInstance, since, "&includeEpisode=true");
|
||||
|
||||
return records
|
||||
.Where(record => record.SeriesId > 0 && record.Episode is not null)
|
||||
.Select(record => (record.SeriesId, record.Episode!.SeasonNumber))
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
|
||||
@@ -330,10 +330,41 @@ public sealed class Seeker : IHandler
|
||||
}
|
||||
}
|
||||
|
||||
HashSet<long> recentlyGrabbedMovieIds = [];
|
||||
HashSet<(long SeriesId, long SeasonNumber)> recentlyGrabbedSeasons = [];
|
||||
|
||||
if (instanceConfig.RecentGrabWindowHours > 0)
|
||||
{
|
||||
DateTimeOffset since = _timeProvider.GetUtcNow().AddHours(-instanceConfig.RecentGrabWindowHours);
|
||||
|
||||
try
|
||||
{
|
||||
if (instanceType == InstanceType.Radarr)
|
||||
{
|
||||
recentlyGrabbedMovieIds = await _radarrClient.GetRecentlyGrabbedMovieIdsAsync(arrInstance, since);
|
||||
}
|
||||
else
|
||||
{
|
||||
recentlyGrabbedSeasons = await _sonarrClient.GetRecentlyGrabbedSeasonsAsync(arrInstance, since);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Skipping proactive search for {InstanceName}, failed to read grab history",
|
||||
arrInstance.Name);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool searched = false;
|
||||
try
|
||||
{
|
||||
searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords, cancellationToken);
|
||||
searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords,
|
||||
recentlyGrabbedMovieIds, recentlyGrabbedSeasons, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -400,6 +431,8 @@ public sealed class Seeker : IHandler
|
||||
InstanceType instanceType,
|
||||
bool isDryRun,
|
||||
List<QueueRecord> queueRecords,
|
||||
HashSet<long> recentlyGrabbedMovieIds,
|
||||
HashSet<(long SeriesId, long SeasonNumber)> recentlyGrabbedSeasons,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Dry run tracks its own cycle through dry history rows.
|
||||
@@ -455,6 +488,7 @@ public sealed class Seeker : IHandler
|
||||
.Where(r => r.MovieId > 0)
|
||||
.Select(r => r.MovieId)
|
||||
.ToHashSet();
|
||||
queuedMovieIds.UnionWith(recentlyGrabbedMovieIds);
|
||||
|
||||
result = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, effectiveCycleId, isDryRun, queuedMovieIds, cancellationToken);
|
||||
}
|
||||
@@ -464,6 +498,7 @@ public sealed class Seeker : IHandler
|
||||
.Where(r => r.SeriesId > 0)
|
||||
.Select(r => (r.SeriesId, r.SeasonNumber))
|
||||
.ToHashSet();
|
||||
queuedSeasons.UnionWith(recentlyGrabbedSeasons);
|
||||
|
||||
result = await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, effectiveCycleId, isDryRun, cancellationToken, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
|
||||
+2071
File diff suppressed because it is too large.
Load diff
+31
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Postgres.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeekerRecentGrabWindow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "recent_grab_window_hours",
|
||||
schema: "data",
|
||||
table: "seeker_instance_configs",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 24);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "recent_grab_window_hours",
|
||||
schema: "data",
|
||||
table: "seeker_instance_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -1731,6 +1731,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("monitored_only");
|
||||
|
||||
b.Property<int>("RecentGrabWindowHours")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("recent_grab_window_hours");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("SkipTags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
|
||||
+2067
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeekerRecentGrabWindow : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "recent_grab_window_hours",
|
||||
table: "seeker_instance_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 24);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "recent_grab_window_hours",
|
||||
table: "seeker_instance_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1727,6 +1727,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("monitored_only");
|
||||
|
||||
b.Property<int>("RecentGrabWindowHours")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("recent_grab_window_hours");
|
||||
|
||||
b.PrimitiveCollection<string>("SkipTags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
|
||||
+5
@@ -90,4 +90,9 @@ public sealed record SeekerInstanceConfig
|
||||
/// Search items whose custom format score is below the quality profile's cutoff format score
|
||||
/// </summary>
|
||||
public bool UseCustomFormatScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Skips items the arr grabbed within this many hours, even after the download left the queue. 0 = disabled.
|
||||
/// </summary>
|
||||
public int RecentGrabWindowHours { get; set; } = 24;
|
||||
}
|
||||
Reference in new issue
Block a user