mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 18:58:59 -04:00
added seeker quiet hours window
This commit is contained in:
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);
|
||||
|
||||
+2096
File diff suppressed because it is too large.
Load diff
+58
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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");
|
||||
|
||||
+2092
File diff suppressed because it is too large.
Load diff
+52
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -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");
|
||||
|
||||
+59
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user