fixed cf upgrade searches skipping grace period

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent 8f2454dc8a
commit a9ff7283bd
11 files changed
+210 -16

No files matched your search

@@ -452,10 +452,11 @@ public class CustomFormatScoreSyncerTests : IDisposable
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]));
DateTimeOffset airDateUtc = new(2026, 1, 15, 0, 0, 0, TimeSpan.Zero);
_sonarrClient
.GetEpisodesAsync(sonarrInstance, 10)
.Returns([
new SearchableEpisode { Id = 100, SeasonNumber = 3, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true }
new SearchableEpisode { Id = 100, SeasonNumber = 3, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true, AirDateUtc = airDateUtc }
]);
_sonarrClient
@@ -469,10 +470,11 @@ public class CustomFormatScoreSyncerTests : IDisposable
// Act
await sut.ExecuteAsync();
// Assert — the synced entry carries the episode's season number
// Assert: the synced entry carries the episode's season number and air date
List<CustomFormatScoreEntry> entries = await _fixture.EventsContext.CustomFormatScoreEntries.ToListAsync();
entries.ShouldHaveSingleItem();
entries[0].SeasonNumber.ShouldBe(3);
entries[0].AirDateUtc.ShouldBe(airDateUtc);
}
[Fact]
@@ -489,7 +491,7 @@ public class CustomFormatScoreSyncerTests : IDisposable
UseCustomFormatScore = true
});
// Pre-existing entry from before SeasonNumber was tracked
// Pre-existing entry from before SeasonNumber/AirDateUtc were tracked
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
@@ -502,6 +504,7 @@ public class CustomFormatScoreSyncerTests : IDisposable
CutoffScore = 500,
QualityProfileName = "HD",
SeasonNumber = null,
AirDateUtc = null,
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
});
await _fixture.DataContext.SaveChangesAsync();
@@ -517,10 +520,11 @@ public class CustomFormatScoreSyncerTests : IDisposable
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
]));
DateTimeOffset airDateUtc = new(2026, 2, 20, 0, 0, 0, TimeSpan.Zero);
_sonarrClient
.GetEpisodesAsync(sonarrInstance, 10)
.Returns([
new SearchableEpisode { Id = 100, SeasonNumber = 2, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true }
new SearchableEpisode { Id = 100, SeasonNumber = 2, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true, AirDateUtc = airDateUtc }
]);
_sonarrClient
@@ -534,10 +538,11 @@ public class CustomFormatScoreSyncerTests : IDisposable
// Act
await sut.ExecuteAsync();
// Assert — the next sync backfills the season number on the existing row
// Assert: the next sync backfills the season number and air date on the existing row
List<CustomFormatScoreEntry> entries = await _fixture.EventsContext.CustomFormatScoreEntries.ToListAsync();
entries.ShouldHaveSingleItem();
entries[0].SeasonNumber.ShouldBe(2);
entries[0].AirDateUtc.ShouldBe(airDateUtc);
}
[Fact]
@@ -3532,6 +3532,7 @@ public class SeekerTests : IDisposable
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddDays(-30),
CurrentScore = 10,
CutoffScore = 50,
Title = "Below CF Cutoff"
@@ -3543,6 +3544,7 @@ public class SeekerTests : IDisposable
EpisodeId = 101,
ItemType = InstanceType.Sonarr,
SeasonNumber = 2,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddDays(-30),
CurrentScore = 60,
CutoffScore = 50,
Title = "Above CF Cutoff"
@@ -3619,6 +3621,7 @@ public class SeekerTests : IDisposable
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddDays(-30),
CurrentScore = 10,
CutoffScore = 50,
Title = "Below CF Cutoff"
@@ -4208,6 +4211,141 @@ public class SeekerTests : IDisposable
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntryWithNullAirDate_IsNotACandidate()
{
// Arrange: a CF score row synced before the air date column existed carries no air date
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
AirDateUtc = null,
CurrentScore = 10,
CutoffScore = 50,
Title = "No Air Date Yet"
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
var mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_sonarrClient
.StreamAllSeriesAsync(sonarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableSeries>([
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } }
]));
_sonarrClient
.GetQualityProfilesAsync(sonarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: no air date to check against the grace period, so nothing was searched
await mockArrClient.DidNotReceive()
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntryWithinGracePeriod_IsNotACandidate()
{
// Arrange: grace period of 24 hours, entry's episode aired 1 hour ago
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
config.PostReleaseGraceHours = 24;
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = sonarrInstance.Id,
ArrInstance = sonarrInstance,
Enabled = true,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddHours(-1),
CurrentScore = 10,
CutoffScore = 50,
Title = "Recently Aired"
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
var mockArrClient = Substitute.For<IArrClient>();
_fixture.ArrQueueReader
.ReadAllAsync(mockArrClient, Arg.Any<ArrInstance>())
.Returns([]);
_sonarrClient
.StreamAllSeriesAsync(sonarrInstance, Arg.Any<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableSeries>([
new SearchableSeries { Id = 10, Title = "Test Series", Status = "continuing", Monitored = true, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } }
]));
_sonarrClient
.GetQualityProfilesAsync(sonarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: episode is within the grace period, so nothing was searched
await mockArrClient.DidNotReceive()
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_CustomFormatScoreEntryWithSeasonNumber_SearchesThatSeasonWithCustomFormatReason()
{
@@ -4237,6 +4375,7 @@ public class SeekerTests : IDisposable
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 3,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddDays(-30),
CurrentScore = 10,
CutoffScore = 50,
Title = "Below CF Cutoff"
@@ -4321,6 +4460,7 @@ public class SeekerTests : IDisposable
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
SeasonNumber = 1,
AirDateUtc = _fixture.TimeProvider.GetUtcNow().AddDays(-30),
CurrentScore = 10,
CutoffScore = 50,
Title = "Below CF Cutoff"
@@ -264,7 +264,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
string profileName = profile?.Name ?? "Unknown";
existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, movie.Monitored, now, seasonNumber: null);
UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, movie.Monitored, now, seasonNumber: null, airDateUtc: null);
synced++;
}
@@ -417,7 +417,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
string title = $"{series.Title} S{episode.SeasonNumber:D2}E{episode.EpisodeNumber:D2}";
existingEntries.TryGetValue((series.Id, episode.Id), out CustomFormatScoreEntry? existing);
UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, isMonitored, now, episode.SeasonNumber);
UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, isMonitored, now, episode.SeasonNumber, episode.AirDateUtc);
synced++;
}
@@ -513,7 +513,8 @@ public sealed class CustomFormatScoreSyncer : IHandler
string profileName,
bool isMonitored,
DateTimeOffset now,
int? seasonNumber)
int? seasonNumber,
DateTimeOffset? airDateUtc)
{
if (existing is not null)
{
@@ -545,6 +546,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
existing.IsMonitored = isMonitored;
existing.LastSyncedAt = now;
existing.SeasonNumber = seasonNumber;
existing.AirDateUtc = airDateUtc;
}
else
{
@@ -562,6 +564,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
IsMonitored = isMonitored,
LastSyncedAt = now,
SeasonNumber = seasonNumber,
AirDateUtc = airDateUtc,
});
// Record initial score in history
@@ -838,12 +838,14 @@ public sealed class Seeker : IHandler
if (instanceConfig.UseCustomFormatScore)
{
// Rows synced before the season column existed carry no season until the next sync.
// Rows synced before the season/air date columns existed carry neither until the next sync.
List<CustomFormatScoreEntry> belowCutoff = await _eventsContext.CustomFormatScoreEntries
.AsNoTracking()
.Where(e => e.ArrInstanceId == arrInstance.Id
&& e.ItemType == InstanceType.Sonarr
&& e.SeasonNumber != null
&& e.AirDateUtc != null
&& e.AirDateUtc <= graceCutoff
&& e.CurrentScore < e.CutoffScore
&& (!instanceConfig.MonitoredOnly || e.IsMonitored))
.ToListAsync(cancellationToken);
@@ -13,8 +13,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Cleanuparr.Persistence.Postgres.Migrations.Events
{
[DbContext(typeof(EventsContext))]
[Migration("20261017120323_AddCustomFormatScoreSeasonNumber")]
partial class AddCustomFormatScoreSeasonNumber
[Migration("20261018093113_AddCustomFormatScoreSeasonAndAirDate")]
partial class AddCustomFormatScoreSeasonAndAirDate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -304,6 +304,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AirDateUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("air_date_utc");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
@@ -1,15 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Events
{
/// <inheritdoc />
public partial class AddCustomFormatScoreSeasonNumber : Migration
public partial class AddCustomFormatScoreSeasonAndAirDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "air_date_utc",
schema: "events",
table: "custom_format_score_entries",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "season_number",
schema: "events",
@@ -21,6 +29,11 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "air_date_utc",
schema: "events",
table: "custom_format_score_entries");
migrationBuilder.DropColumn(
name: "season_number",
schema: "events",
@@ -301,6 +301,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset?>("AirDateUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("air_date_utc");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("uuid")
.HasColumnName("arr_instance_id");
@@ -11,8 +11,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Cleanuparr.Persistence.Migrations.Events
{
[DbContext(typeof(EventsContext))]
[Migration("20261017120316_AddCustomFormatScoreSeasonNumber")]
partial class AddCustomFormatScoreSeasonNumber
[Migration("20261018093103_AddCustomFormatScoreSeasonAndAirDate")]
partial class AddCustomFormatScoreSeasonAndAirDate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -299,6 +299,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AirDateUtc")
.HasColumnType("TEXT")
.HasColumnName("air_date_utc");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
@@ -5,11 +5,17 @@
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class AddCustomFormatScoreSeasonNumber : Migration
public partial class AddCustomFormatScoreSeasonAndAirDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "air_date_utc",
table: "custom_format_score_entries",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "season_number",
table: "custom_format_score_entries",
@@ -20,6 +26,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "air_date_utc",
table: "custom_format_score_entries");
migrationBuilder.DropColumn(
name: "season_number",
table: "custom_format_score_entries");
@@ -296,6 +296,10 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AirDateUtc")
.HasColumnType("TEXT")
.HasColumnName("air_date_utc");
b.Property<Guid>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
@@ -80,4 +80,9 @@ public sealed record CustomFormatScoreEntry
/// Sonarr season of the episode; null for Radarr and for rows not yet re-synced.
/// </summary>
public int? SeasonNumber { get; set; }
/// <summary>
/// Sonarr episode air date; null for Radarr and for rows not yet re-synced.
/// </summary>
public DateTimeOffset? AirDateUtc { get; set; }
}