added seeker quiet hours window

This commit is contained in:
Flaminel committed 2026-10-28 22:27:50 +02:00
1 parent 6f9ada2de5
commit bf55a745de
10 files changed
+4542

No files matched your search

@@ -244,6 +244,121 @@ public class SeekerTests : IDisposable
Arg.Any<SeekerContentKind?>());
}
[Fact]
public async Task ExecuteAsync_DuringQuietHours_SkipsProactiveSearch()
{
IArrClient mockArrClient = await ArrangeRadarrProactiveSearchAsync(new TimeOnly(23, 0), new TimeOnly(1, 0));
await CreateSut().ExecuteAsync();
await mockArrClient.DidNotReceive().SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
_fixture.ArrClientFactory.DidNotReceive().GetClient(Arg.Any<InstanceType>(), Arg.Any<float>());
}
[Fact]
public async Task ExecuteAsync_OutsideQuietHours_RunsProactiveSearch()
{
IArrClient mockArrClient = await ArrangeRadarrProactiveSearchAsync(new TimeOnly(1, 0), new TimeOnly(2, 0));
await CreateSut().ExecuteAsync();
await mockArrClient.Received(1).SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_QuietHours_UsesLocalTime()
{
// Local 05:00 (UTC+5) sits inside 04:00-06:00, UTC 00:00 does not
_fixture.TimeProvider.SetLocalTimeZone(TimeZoneInfo.CreateCustomTimeZone("test", TimeSpan.FromHours(5), "test", "test"));
IArrClient mockArrClient = await ArrangeRadarrProactiveSearchAsync(new TimeOnly(4, 0), new TimeOnly(6, 0));
await CreateSut().ExecuteAsync();
await mockArrClient.DidNotReceive().SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_DuringQuietHours_StillProcessesReplacement()
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.QuietHoursEnabled = true;
config.QuietHoursStart = new TimeOnly(23, 0);
config.QuietHoursEnd = new TimeOnly(1, 0);
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.EventsContext.SearchQueue.Add(new SearchQueueItem
{
ArrInstanceId = radarrInstance.Id,
ItemId = 42,
Title = "Test Movie",
CreatedAt = DateTime.UtcNow
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
mockArrClient
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
await CreateSut().ExecuteAsync();
await mockArrClient.Received(1).SearchItemAsync(radarrInstance, Arg.Any<SearchItem>());
}
/// <summary>
/// Arranges one enabled Radarr instance with a single missing movie, quiet hours on.
/// The fake clock starts at midnight UTC.
/// </summary>
private async Task<IArrClient> ArrangeRadarrProactiveSearchAsync(TimeOnly quietStart, TimeOnly quietEnd)
{
SeekerConfig config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.QuietHoursEnabled = true;
config.QuietHoursStart = quietStart;
config.QuietHoursEnd = quietEnd;
ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = true
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
List<SearchableMovie> movies =
[
new SearchableMovie { Id = 1, Title = "Movie", Status = "released", Monitored = true, Tags = [] }
];
_radarrClient
.StreamAllMoviesAsync(radarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>(movies));
StubRadarrWanted(radarrInstance, movies);
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
return mockArrClient;
}
[Fact]
public async Task ExecuteAsync_WhenReplacementItemExists_ProcessesReplacementFirst()
{
@@ -111,6 +111,12 @@ public sealed class Seeker : IHandler
return;
}
if (config.IsQuietTime(TimeOnly.FromDateTime(_timeProvider.GetLocalNow().DateTime)))
{
_logger.LogDebug("Quiet hours, skipping proactive search");
return;
}
await ProcessProactiveSearchAsync(config, isDryRun, cancellationToken);
await _statusNotifier.NotifySearchStatsUpdatedAsync(cancellationToken);
@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerQuietHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "quiet_hours_enabled",
schema: "data",
table: "seeker_configs",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<TimeOnly>(
name: "quiet_hours_end",
schema: "data",
table: "seeker_configs",
type: "time without time zone",
nullable: false,
defaultValue: new TimeOnly(7, 0, 0));
migrationBuilder.AddColumn<TimeOnly>(
name: "quiet_hours_start",
schema: "data",
table: "seeker_configs",
type: "time without time zone",
nullable: false,
defaultValue: new TimeOnly(23, 0, 0));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "quiet_hours_enabled",
schema: "data",
table: "seeker_configs");
migrationBuilder.DropColumn(
name: "quiet_hours_end",
schema: "data",
table: "seeker_configs");
migrationBuilder.DropColumn(
name: "quiet_hours_start",
schema: "data",
table: "seeker_configs");
}
}
}
@@ -1668,6 +1668,18 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data
.HasColumnType("boolean")
.HasColumnName("proactive_search_enabled");
b.Property<bool>("QuietHoursEnabled")
.HasColumnType("boolean")
.HasColumnName("quiet_hours_enabled");
b.Property<TimeOnly>("QuietHoursEnd")
.HasColumnType("time without time zone")
.HasColumnName("quiet_hours_end");
b.Property<TimeOnly>("QuietHoursStart")
.HasColumnType("time without time zone")
.HasColumnName("quiet_hours_start");
b.Property<bool>("SearchEnabled")
.HasColumnType("boolean")
.HasColumnName("search_enabled");
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddSeekerQuietHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "quiet_hours_enabled",
table: "seeker_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<TimeOnly>(
name: "quiet_hours_end",
table: "seeker_configs",
type: "TEXT",
nullable: false,
defaultValue: new TimeOnly(7, 0, 0));
migrationBuilder.AddColumn<TimeOnly>(
name: "quiet_hours_start",
table: "seeker_configs",
type: "TEXT",
nullable: false,
defaultValue: new TimeOnly(23, 0, 0));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "quiet_hours_enabled",
table: "seeker_configs");
migrationBuilder.DropColumn(
name: "quiet_hours_end",
table: "seeker_configs");
migrationBuilder.DropColumn(
name: "quiet_hours_start",
table: "seeker_configs");
}
}
}
@@ -1664,6 +1664,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("proactive_search_enabled");
b.Property<bool>("QuietHoursEnabled")
.HasColumnType("INTEGER")
.HasColumnName("quiet_hours_enabled");
b.Property<TimeOnly>("QuietHoursEnd")
.HasColumnType("TEXT")
.HasColumnName("quiet_hours_end");
b.Property<TimeOnly>("QuietHoursStart")
.HasColumnType("TEXT")
.HasColumnName("quiet_hours_start");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
@@ -99,4 +99,63 @@ public sealed class SeekerConfigTests
}
#endregion
#region Quiet Hours
[Fact]
public void Validate_WithEqualQuietHours_ThrowsValidationException()
{
SeekerConfig config = new()
{
QuietHoursStart = new TimeOnly(5, 0),
QuietHoursEnd = new TimeOnly(5, 0)
};
Should.Throw<CustomValidationException>(() => config.Validate());
}
[Fact]
public void IsQuietTime_WhenDisabled_ReturnsFalse()
{
SeekerConfig config = new() { QuietHoursEnabled = false };
config.IsQuietTime(new TimeOnly(23, 30)).ShouldBeFalse();
}
[Theory]
[InlineData(0, 59, false)]
[InlineData(1, 0, true)]
[InlineData(4, 59, true)]
[InlineData(5, 0, false)]
public void IsQuietTime_WithSameDayWindow_RespectsBounds(int hour, int minute, bool expected)
{
SeekerConfig config = new()
{
QuietHoursEnabled = true,
QuietHoursStart = new TimeOnly(1, 0),
QuietHoursEnd = new TimeOnly(5, 0)
};
config.IsQuietTime(new TimeOnly(hour, minute)).ShouldBe(expected);
}
[Theory]
[InlineData(22, 59, false)]
[InlineData(23, 0, true)]
[InlineData(0, 0, true)]
[InlineData(6, 59, true)]
[InlineData(7, 0, false)]
public void IsQuietTime_WithOvernightWindow_RespectsBounds(int hour, int minute, bool expected)
{
SeekerConfig config = new()
{
QuietHoursEnabled = true,
QuietHoursStart = new TimeOnly(23, 0),
QuietHoursEnd = new TimeOnly(7, 0)
};
config.IsQuietTime(new TimeOnly(hour, minute)).ShouldBe(expected);
}
#endregion
}
@@ -49,6 +49,23 @@ public sealed record SeekerConfig : IConfig
/// </summary>
public int PostReleaseGraceHours { get; set; } = 6;
/// <summary>
/// Pauses proactive search during the quiet hours window.
/// Replacement searches keep running.
/// </summary>
public bool QuietHoursEnabled { get; set; }
/// <summary>
/// Local time of day when quiet hours begin (inclusive).
/// </summary>
public TimeOnly QuietHoursStart { get; set; } = new(23, 0);
/// <summary>
/// Local time of day when quiet hours end (exclusive).
/// An end earlier than the start crosses midnight.
/// </summary>
public TimeOnly QuietHoursEnd { get; set; } = new(7, 0);
public void Validate()
{
if (SearchInterval < Constants.MinSearchIntervalMinutes)
@@ -72,6 +89,29 @@ public sealed record SeekerConfig : IConfig
{
throw new CustomValidationException($"{nameof(PostReleaseGraceHours)} must be between 0 and 72");
}
if (QuietHoursStart == QuietHoursEnd)
{
throw new CustomValidationException($"{nameof(QuietHoursStart)} and {nameof(QuietHoursEnd)} must differ");
}
}
/// <summary>
/// Whether proactive search must wait at this local time of day.
/// </summary>
public bool IsQuietTime(TimeOnly time)
{
if (!QuietHoursEnabled)
{
return false;
}
if (QuietHoursStart < QuietHoursEnd)
{
return time >= QuietHoursStart && time < QuietHoursEnd;
}
return time >= QuietHoursStart || time < QuietHoursEnd;
}
/// <summary>