added radarr availability release gate to seeker

This commit is contained in:
Flaminel committed 2026-10-26 15:57:47 +02:00
1 parent 09458e974c
commit 469467249a
18 files changed
+4446 -10

No files matched your search

@@ -60,6 +60,7 @@ public class SeekerConfigControllerTests : IDisposable
instance.MinCycleTimeDays.ShouldBe(7);
instance.RecentGrabWindowHours.ShouldBe(24);
instance.SonarrSearchMode.ShouldBe(SonarrSearchMode.Auto);
instance.RadarrReleaseGate.ShouldBe(RadarrReleaseGate.ReleaseDate);
}
[Fact]
@@ -141,6 +142,25 @@ public class SeekerConfigControllerTests : IDisposable
instance.RestMaxDays.ShouldBe(90);
}
[Fact]
public async Task GetSeekerConfig_WithRadarrReleaseGate_ReturnsStoredValue()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true,
RadarrReleaseGate = RadarrReleaseGate.ArrAvailability
});
await _dataContext.SaveChangesAsync();
IActionResult result = await _controller.GetSeekerConfig();
OkObjectResult okResult = result.ShouldBeOfType<OkObjectResult>();
SeekerConfigResponse response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
response.Instances.ShouldHaveSingleItem().RadarrReleaseGate.ShouldBe(RadarrReleaseGate.ArrAvailability);
}
[Fact]
public async Task GetSeekerConfig_WithSonarrSearchMode_ReturnsStoredValue()
{
@@ -614,6 +634,40 @@ public class SeekerConfigControllerTests : IDisposable
config.SonarrSearchMode.ShouldBe(SonarrSearchMode.Season);
}
[Fact]
public async Task UpdateSeekerConfig_WithRadarrReleaseGate_OnExistingInstance_PersistsValue()
{
ArrInstance radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarr.Id,
Enabled = true,
RadarrReleaseGate = RadarrReleaseGate.ReleaseDate
});
await _dataContext.SaveChangesAsync();
UpdateSeekerConfigRequest request = new()
{
SearchEnabled = true,
SearchInterval = 3,
ProactiveSearchEnabled = true,
Instances =
[
new UpdateSeekerInstanceConfigRequest
{
ArrInstanceId = radarr.Id,
Enabled = true,
RadarrReleaseGate = RadarrReleaseGate.ArrAvailability
}
]
};
await _controller.UpdateSeekerConfig(request);
SeekerInstanceConfig config = await _dataContext.SeekerInstanceConfigs.SingleAsync();
config.RadarrReleaseGate.ShouldBe(RadarrReleaseGate.ArrAvailability);
}
[Theory]
[InlineData(-1)]
[InlineData(169)]
@@ -28,6 +28,8 @@ public sealed record UpdateSeekerInstanceConfigRequest
public SonarrSearchMode SonarrSearchMode { get; init; } = SonarrSearchMode.Auto;
public RadarrReleaseGate RadarrReleaseGate { get; init; } = RadarrReleaseGate.ReleaseDate;
public bool RestUnitsEnabled { get; init; }
public int RestMaxDays { get; init; } = 30;
@@ -36,6 +36,8 @@ public sealed record SeekerInstanceConfigResponse
public SonarrSearchMode SonarrSearchMode { get; init; }
public RadarrReleaseGate RadarrReleaseGate { get; init; }
public bool RestUnitsEnabled { get; init; }
public int RestMaxDays { get; init; }
@@ -72,6 +72,7 @@ public sealed class SeekerConfigController : ControllerBase
UseCustomFormatScore = seekerConfig?.UseCustomFormatScore ?? false,
RecentGrabWindowHours = seekerConfig?.RecentGrabWindowHours ?? 24,
SonarrSearchMode = seekerConfig?.SonarrSearchMode ?? SonarrSearchMode.Auto,
RadarrReleaseGate = seekerConfig?.RadarrReleaseGate ?? RadarrReleaseGate.ReleaseDate,
RestUnitsEnabled = seekerConfig?.RestUnitsEnabled ?? false,
RestMaxDays = seekerConfig?.RestMaxDays ?? 30,
};
@@ -143,6 +144,7 @@ public sealed class SeekerConfigController : ControllerBase
existing.UseCustomFormatScore = instanceReq.UseCustomFormatScore;
existing.RecentGrabWindowHours = instanceReq.RecentGrabWindowHours;
existing.SonarrSearchMode = instanceReq.SonarrSearchMode;
existing.RadarrReleaseGate = instanceReq.RadarrReleaseGate;
existing.RestUnitsEnabled = instanceReq.RestUnitsEnabled;
existing.RestMaxDays = instanceReq.RestMaxDays;
existing.Validate();
@@ -163,6 +165,7 @@ public sealed class SeekerConfigController : ControllerBase
UseCustomFormatScore = instanceReq.UseCustomFormatScore,
RecentGrabWindowHours = instanceReq.RecentGrabWindowHours,
SonarrSearchMode = instanceReq.SonarrSearchMode,
RadarrReleaseGate = instanceReq.RadarrReleaseGate,
RestUnitsEnabled = instanceReq.RestUnitsEnabled,
RestMaxDays = instanceReq.RestMaxDays,
};
@@ -26,6 +26,11 @@ public sealed record SearchableMovie
public DateTimeOffset? InCinemas { get; init; }
/// <summary>
/// Radarr's own verdict from the movie's minimum availability and the availability delay.
/// </summary>
public bool IsAvailable { get; init; }
/// <summary>
/// Set when a search reached an indexer; RSS grabs leave it alone.
/// </summary>
@@ -0,0 +1,15 @@
namespace Cleanuparr.Domain.Enums;
public enum RadarrReleaseGate
{
/// <summary>
/// Searches a movie once Radarr marks it released and its digital, physical or cinema date is past the grace period.
/// </summary>
ReleaseDate,
/// <summary>
/// Searches a movie once Radarr counts it as available, using its minimum availability and availability delay.
/// The grace period does not apply.
/// </summary>
ArrAvailability,
}
@@ -62,4 +62,9 @@ public enum SeekerExclusionReason
/// Already searched in the current cycle
/// </summary>
SearchedThisCycle,
/// <summary>
/// Radarr does not count the movie as available yet
/// </summary>
NotAvailable,
}
@@ -296,7 +296,7 @@ public class RadarrClientTests
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new SearchableMovie { Id = 1, Title = "Movie", QualityProfileId = 1 },
new SearchableMovie { Id = 1, Title = "Movie", QualityProfileId = 1, IsAvailable = true },
})));
// Act
@@ -309,6 +309,7 @@ public class RadarrClientTests
// Assert
movies.Count.ShouldBe(1);
movies[0].Title.ShouldBe("Movie");
movies[0].IsAvailable.ShouldBeTrue();
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/movie");
}
@@ -81,7 +81,8 @@ public class SeekerUnitEvaluatorTests : IDisposable
int qualityProfileId = 1,
DateTimeOffset? digitalRelease = null,
DateTimeOffset? added = null,
DateTimeOffset? lastSearchTime = null)
DateTimeOffset? lastSearchTime = null,
bool isAvailable = false)
{
return new SearchableMovie
{
@@ -95,6 +96,7 @@ public class SeekerUnitEvaluatorTests : IDisposable
DigitalRelease = digitalRelease,
Added = added,
LastSearchTime = lastSearchTime,
IsAvailable = isAvailable,
};
}
@@ -299,6 +301,63 @@ public class SeekerUnitEvaluatorTests : IDisposable
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.InGracePeriod);
}
[Fact]
public async Task EvaluateAsync_Radarr_ArrAvailability_InCinemasMovieThatIsAvailable_BuildsUnit()
{
// Arrange
SetupRadarr([Movie(1, status: "inCinemas", isAvailable: true)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
InstanceConfig(_radarr, c => c.RadarrReleaseGate = RadarrReleaseGate.ArrAvailability));
// Assert
evaluation.Units.ShouldHaveSingleItem().Name.ShouldBe("Movie 1");
}
[Fact]
public async Task EvaluateAsync_Radarr_ArrAvailability_ReleasedMovieThatIsNotAvailable_ExcludesAsNotAvailable()
{
// Arrange
SetupRadarr([Movie(1, digitalRelease: _timeProvider.GetUtcNow().AddDays(-30), isAvailable: false)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
InstanceConfig(_radarr, c => c.RadarrReleaseGate = RadarrReleaseGate.ArrAvailability));
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NotAvailable);
evaluation.Units.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Radarr_ArrAvailability_AvailableMovieInsideGraceWindow_BuildsUnit()
{
// Arrange
SetupRadarr([Movie(1, digitalRelease: _timeProvider.GetUtcNow().AddHours(-1), isAvailable: true)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync(
InstanceConfig(_radarr, c => c.RadarrReleaseGate = RadarrReleaseGate.ArrAvailability));
// Assert
evaluation.Units.ShouldHaveSingleItem().Name.ShouldBe("Movie 1");
}
[Fact]
public async Task EvaluateAsync_Radarr_ReleaseDate_InCinemasMovieThatIsAvailable_ExcludesAsNotReleased()
{
// Arrange
SetupRadarr([Movie(1, status: "inCinemas", isAvailable: true)]);
// Act
SeekerEvaluation evaluation = await EvaluateRadarrAsync();
// Assert
ReasonOf(evaluation, "Movie 1").ShouldBe(SeekerExclusionReason.NotReleased);
evaluation.Units.ShouldBeEmpty();
}
[Fact]
public async Task EvaluateAsync_Radarr_UnmonitoredWithMonitoredOnly_ExcludesAsNotMonitored()
{
@@ -32,6 +32,7 @@ public class EnumFallbackParityTests
["RTorrentSeedingRule.PrivacyType"] = TorrentPrivacyType.Public,
["SeekerConfig.SelectionStrategy"] = SelectionStrategy.BalancedWeighted,
["SeekerInstanceConfig.SonarrSearchMode"] = SonarrSearchMode.Auto,
["SeekerInstanceConfig.RadarrReleaseGate"] = RadarrReleaseGate.ReleaseDate,
["NtfyConfig.AuthenticationType"] = NtfyAuthenticationType.None,
["NtfyConfig.Priority"] = NtfyPriority.Default,
["PushoverConfig.Priority"] = PushoverPriority.Normal,
@@ -309,15 +309,10 @@ public sealed class SeekerUnitEvaluator
{
allLibraryIds.Add(movie.Id);
if (movie.Status is not "released")
SeekerExclusionReason? releaseExclusion = GetReleaseExclusion(movie, instanceConfig.RadarrReleaseGate, graceCutoff);
if (releaseExclusion is not null)
{
exclusions.Add(new SeekerExclusion(movie.Title, SeekerExclusionReason.NotReleased));
continue;
}
if (!IsMoviePastGracePeriod(movie, graceCutoff))
{
exclusions.Add(new SeekerExclusion(movie.Title, SeekerExclusionReason.InGracePeriod));
exclusions.Add(new SeekerExclusion(movie.Title, releaseExclusion.Value));
continue;
}
@@ -946,6 +941,27 @@ public sealed class SeekerUnitEvaluator
return releaseDate is null || releaseDate.Value <= graceCutoff;
}
/// <summary>
/// Returns why the release gate keeps the movie out, or null when it lets it through.
/// </summary>
private static SeekerExclusionReason? GetReleaseExclusion(
SearchableMovie movie,
RadarrReleaseGate gate,
DateTimeOffset graceCutoff)
{
if (gate is RadarrReleaseGate.ArrAvailability)
{
return movie.IsAvailable ? null : SeekerExclusionReason.NotAvailable;
}
if (movie.Status is not "released")
{
return SeekerExclusionReason.NotReleased;
}
return IsMoviePastGracePeriod(movie, graceCutoff) ? null : SeekerExclusionReason.InGracePeriod;
}
/// <summary>
/// Returns why the tags keep the item out, or null when they let it through. A skip tag wins over an include tag.
/// </summary>
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerRadarrReleaseGate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "radarr_release_gate",
schema: "data",
table: "seeker_instance_configs",
type: "text",
nullable: false,
defaultValue: "releasedate");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "radarr_release_gate",
schema: "data",
table: "seeker_instance_configs");
}
}
}
@@ -1747,6 +1747,11 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data
.HasColumnType("boolean")
.HasColumnName("monitored_only");
b.Property<string>("RadarrReleaseGate")
.IsRequired()
.HasColumnType("text")
.HasColumnName("radarr_release_gate");
b.Property<int>("RecentGrabWindowHours")
.HasColumnType("integer")
.HasColumnName("recent_grab_window_hours");
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerRadarrReleaseGate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "radarr_release_gate",
table: "seeker_instance_configs",
type: "TEXT",
nullable: false,
defaultValue: "releasedate");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "radarr_release_gate",
table: "seeker_instance_configs");
}
}
}
@@ -1743,6 +1743,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("monitored_only");
b.Property<string>("RadarrReleaseGate")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_release_gate");
b.Property<int>("RecentGrabWindowHours")
.HasColumnType("INTEGER")
.HasColumnName("recent_grab_window_hours");
@@ -97,6 +97,11 @@ public sealed record SeekerInstanceConfig
/// </summary>
public SonarrSearchMode SonarrSearchMode { get; set; } = SonarrSearchMode.Auto;
/// <summary>
/// How Radarr movies count as released. Ignored for Sonarr.
/// </summary>
public RadarrReleaseGate RadarrReleaseGate { get; set; } = RadarrReleaseGate.ReleaseDate;
/// <summary>
/// Skips units whose recent searches in a row found nothing, for a rest that doubles with each further miss.
/// </summary>