fixed seeker searching cf upgrades on locked profiles

This commit is contained in:
Flaminel committed 2026-10-20 15:55:50 +03:00
1 parent 51b8776776
commit edafb30282
3 files changed
+453 -7

No files matched your search

@@ -6,5 +6,7 @@ public sealed record ArrQualityProfile
public string Name { get; init; } = string.Empty;
public bool UpgradeAllowed { get; init; }
public int CutoffFormatScore { get; init; }
}
@@ -2567,6 +2567,10 @@ public class SeekerTests : IDisposable
new SearchableMovie { Id = 3, Title = "Above Cutoff", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = false }, Tags = [] }
]));
_radarrClient
.GetQualityProfilesAsync(radarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]);
SearchItem? capturedSearchItem = null;
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
@@ -2660,6 +2664,10 @@ public class SeekerTests : IDisposable
new SearchableMovie { Id = 4, Title = "Both Met", Status = "released", Monitored = true, HasFile = true, MovieFile = new MovieFileInfo { Id = 400, QualityCutoffNotMet = false }, Tags = [] }
]));
_radarrClient
.GetQualityProfilesAsync(radarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]);
List<SearchItem> capturedSearchItems = [];
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
@@ -2685,6 +2693,265 @@ public class SeekerTests : IDisposable
capturedSearchItems.ShouldNotContain(item => item.Id == 4);
}
[Fact]
public async Task ExecuteAsync_Radarr_UseCustomFormatScore_UpgradesDisabledProfile_ExcludesBelowCutoffMovie()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 2,
ItemType = InstanceType.Radarr,
CurrentScore = 10,
CutoffScore = 50,
Title = "Below Cutoff"
});
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>([
new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }
]));
_radarrClient
.GetQualityProfilesAsync(radarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockArrClient.DidNotReceive()
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Radarr_UseCustomFormatScore_UpgradesDisabledProfile_SearchesOnlyMissingMovie()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = radarrInstance.Id,
ExternalItemId = 2,
ItemType = InstanceType.Radarr,
CurrentScore = 10,
CutoffScore = 50,
Title = "Below Cutoff"
});
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>([
new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, QualityProfileId = 0, Tags = [] },
new SearchableMovie { Id = 2, Title = "Below Cutoff", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false }, Tags = [] }
]));
_radarrClient
.GetQualityProfilesAsync(radarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]);
SearchItem? capturedSearchItem = null;
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(ci =>
{
capturedSearchItem = ci.ArgAt<SearchItem>(1);
return 100L;
});
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
capturedSearchItem.ShouldNotBeNull();
capturedSearchItem.Id.ShouldBe(1);
}
[Fact]
public async Task ExecuteAsync_Radarr_UseCutoffAndUseCustomFormatScore_UpgradesDisabledProfile_StillSearchesCutoffNotMet()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = true,
UseCustomFormatScore = true
});
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>([
new SearchableMovie { Id = 3, Title = "Cutoff Not Met", Status = "released", Monitored = true, HasFile = true, QualityProfileId = 0, MovieFile = new MovieFileInfo { Id = 300, QualityCutoffNotMet = true }, Tags = [] }
]));
_radarrClient
.GetQualityProfilesAsync(radarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]);
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.EventPublisher.Received(1)
.PublishSearchTriggered(
"Cutoff Not Met",
SeekerSearchType.Proactive,
SeekerSearchReason.QualityCutoffNotMet,
Arg.Any<Guid?>(),
Arg.Any<bool?>());
}
[Fact]
public async Task ExecuteAsync_Radarr_UseCustomFormatScoreDisabled_DoesNotFetchQualityProfiles()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = false
});
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>([
new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] }
]));
mockArrClient
.SearchItemAsync(radarrInstance, Arg.Any<SearchItem>())
.Returns(100L);
_fixture.ArrClientFactory
.GetClient(InstanceType.Radarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _radarrClient.DidNotReceive()
.GetQualityProfilesAsync(Arg.Any<ArrInstance>());
}
[Fact]
public async Task ExecuteAsync_Radarr_SearchReason_Missing()
{
@@ -3024,6 +3291,10 @@ public class SeekerTests : IDisposable
new SearchableEpisode { Id = 101, SeasonNumber = 2, EpisodeNumber = 1, Monitored = true, AirDateUtc = pastDate, HasFile = true, EpisodeFileId = 600 }
]);
_sonarrClient
.GetQualityProfilesAsync(sonarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = true }]);
SeriesSearchItem? capturedSearchItem = null;
mockArrClient
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>())
@@ -3047,6 +3318,138 @@ public class SeekerTests : IDisposable
capturedSearchItem.Id.ShouldBe(1); // Season 1
}
[Fact]
public async Task ExecuteAsync_Sonarr_UseCustomFormatScore_UpgradesDisabledProfile_ExcludesBelowCutoffEpisode()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
_fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
{
ArrInstanceId = sonarrInstance.Id,
ExternalItemId = 10,
EpisodeId = 100,
ItemType = InstanceType.Sonarr,
CurrentScore = 10,
CutoffScore = 50,
Title = "Below CF Cutoff"
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient 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, QualityProfileId = 0, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 9 } }
]));
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 = true, EpisodeFileId = 500 }
]);
_sonarrClient
.GetQualityProfilesAsync(sonarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _sonarrClient.Received().GetEpisodesAsync(Arg.Any<ArrInstance>(), 10);
await mockArrClient.DidNotReceive()
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_FullyDownloaded_UpgradesDisabledProfile_SkipsEpisodeFetch()
{
// Arrange
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,
MonitoredOnly = false,
UseCutoff = false,
UseCustomFormatScore = true
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
IArrClient 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 = "Fully Downloaded Series", Status = "continuing", Monitored = true, QualityProfileId = 0, Tags = [], Statistics = new SeriesStatistics { EpisodeCount = 10, EpisodeFileCount = 10 } }
]));
_sonarrClient
.GetQualityProfilesAsync(sonarrInstance)
.Returns([new ArrQualityProfile { Id = 0, UpgradeAllowed = false }]);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
SeekerJob sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _sonarrClient.DidNotReceive()
.GetEpisodesAsync(Arg.Any<ArrInstance>(), 10);
await mockArrClient.DidNotReceive()
.SearchItemAsync(Arg.Any<ArrInstance>(), Arg.Any<SearchItem>());
}
[Fact]
public async Task ExecuteAsync_Sonarr_MonitoredOnlyTrue_ExcludesUnmonitoredSeries()
{
@@ -534,12 +534,16 @@ public sealed class Seeker : IHandler
// Load cached CF scores when custom format score filtering is enabled
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
HashSet<int> upgradesDisabledProfileIds = [];
if (instanceConfig.UseCustomFormatScore)
{
cfScores = await _eventsContext.CustomFormatScoreEntries
.AsNoTracking()
.Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Radarr)
.ToDictionaryAsync(e => e.ExternalItemId);
List<ArrQualityProfile> profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance);
upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet();
}
DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours);
@@ -573,11 +577,18 @@ public sealed class Seeker : IHandler
continue;
}
bool belowCustomFormatCutoff = instanceConfig.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(movie.Id, out var entry) && entry.CurrentScore < entry.CutoffScore;
bool upgradesDisabled = belowCustomFormatCutoff && upgradesDisabledProfileIds.Contains(movie.QualityProfileId);
bool passesQualityCheck = !movie.HasFile
|| (instanceConfig.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false))
|| (instanceConfig.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(movie.Id, out var entry) && entry.CurrentScore < entry.CutoffScore);
|| (belowCustomFormatCutoff && !upgradesDisabled);
if (!passesQualityCheck)
{
if (upgradesDisabled)
{
_logger.LogDebug("skip | upgrades disabled in profile | {Title} | {InstanceName}", movie.Title, arrInstance.Name);
}
continue;
}
@@ -699,6 +710,13 @@ public sealed class Seeker : IHandler
Dictionary<long, string> tagsById = tags.ToDictionary(t => t.Id, t => t.Label);
HashSet<string> skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase);
HashSet<int> upgradesDisabledProfileIds = [];
if (instanceConfig.UseCustomFormatScore)
{
List<ArrQualityProfile> profiles = await _sonarrClient.GetQualityProfilesAsync(arrInstance);
upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet();
}
DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours);
List<long> allLibraryIds = new();
@@ -729,8 +747,15 @@ public sealed class Seeker : IHandler
bool fullyDownloaded = series.Statistics is not null
&& series.Statistics.EpisodeCount > 0
&& series.Statistics.EpisodeFileCount >= series.Statistics.EpisodeCount;
if (fullyDownloaded && !instanceConfig.UseCutoff && !instanceConfig.UseCustomFormatScore)
bool customFormatUpgradesAllowed = instanceConfig.UseCustomFormatScore
&& !upgradesDisabledProfileIds.Contains(series.QualityProfileId);
if (fullyDownloaded && !instanceConfig.UseCutoff && !customFormatUpgradesAllowed)
{
if (instanceConfig.UseCustomFormatScore)
{
_logger.LogDebug("skip | upgrades disabled in profile | {SeriesTitle} | {InstanceName}", series.Title, arrInstance.Name);
}
continue;
}
@@ -775,7 +800,8 @@ public sealed class Seeker : IHandler
seriesTitle = series.Title;
(SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode, SeekerSearchReason searchReason) =
await BuildSonarrSearchItemAsync(instanceConfig, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff, cancellationToken, queuedSeasons);
await BuildSonarrSearchItemAsync(instanceConfig, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff,
!upgradesDisabledProfileIds.Contains(series.QualityProfileId), cancellationToken, queuedSeasons);
if (searchItem is not null)
{
@@ -858,6 +884,7 @@ public sealed class Seeker : IHandler
List<SeekerHistory> seriesHistory,
string seriesTitle,
DateTimeOffset graceCutoff,
bool customFormatUpgradesAllowed,
CancellationToken cancellationToken,
HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null)
{
@@ -886,18 +913,33 @@ public sealed class Seeker : IHandler
.ToDictionaryAsync(e => e.EpisodeId);
}
bool BelowCustomFormatCutoff(SearchableEpisode e) =>
cfScores != null && cfScores.TryGetValue(e.Id, out var entry) && entry.CurrentScore < entry.CutoffScore;
// Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed.
// Cutoff status comes from the episodefile endpoint; items without a cached CF score are excluded.
var qualifying = episodes
List<SearchableEpisode> released = episodes
.Where(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff)
.Where(e => !instanceConfig.MonitoredOnly || e.Monitored)
.ToList();
var qualifying = released
.Where(e => !e.HasFile
|| (instanceConfig.UseCutoff && cutoffNotMetFileIds.Contains(e.EpisodeFileId))
|| (instanceConfig.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(e.Id, out var entry) && entry.CurrentScore < entry.CutoffScore))
|| (customFormatUpgradesAllowed && BelowCustomFormatCutoff(e)))
.OrderBy(e => e.SeasonNumber)
.ThenBy(e => e.EpisodeNumber)
.ToList();
if (!customFormatUpgradesAllowed)
{
foreach (SearchableEpisode e in released.Where(e => BelowCustomFormatCutoff(e) && !qualifying.Contains(e)))
{
_logger.LogDebug("skip | upgrades disabled in profile | {SeriesTitle} S{Season:D2}E{Episode:D2} | {InstanceName}",
seriesTitle, e.SeasonNumber, e.EpisodeNumber, arrInstance.Name);
}
}
if (qualifying.Count == 0)
{
return (null, null, default);
@@ -949,8 +991,7 @@ public sealed class Seeker : IHandler
var seasonEpisodes = qualifying.Where(e => e.SeasonNumber == selected.SeasonNumber).ToList();
int missingCount = seasonEpisodes.Count(e => !e.HasFile);
int cutoffCount = seasonEpisodes.Count(e => e.HasFile && cutoffNotMetFileIds.Contains(e.EpisodeFileId));
int cfCount = seasonEpisodes.Count(e => e.HasFile && cfScores != null
&& cfScores.TryGetValue(e.Id, out var cfEntry) && cfEntry.CurrentScore < cfEntry.CutoffScore);
int cfCount = seasonEpisodes.Count(e => e.HasFile && customFormatUpgradesAllowed && BelowCustomFormatCutoff(e));
List<string> reasons = [];
if (missingCount > 0)