added recent grab window to seeker instances

This commit is contained in:
Flaminel committed 2026-10-20 15:55:52 +03:00
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
@@ -21,4 +21,6 @@ public sealed record UpdateSeekerInstanceConfigRequest
public bool UseCutoff { get; init; }
public bool UseCustomFormatScore { get; init; }
public int RecentGrabWindowHours { get; init; } = 24;
}
@@ -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. "&amp;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);
}
@@ -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");
}
}
}
@@ -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[]")
@@ -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")
@@ -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;
}