From edafb30282a2dc2b22036c87f5e356382ce1c0f4 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 17 Oct 2026 02:10:18 +0300 Subject: [PATCH] fixed seeker searching cf upgrades on locked profiles --- .../Entities/Arr/ArrQualityProfile.cs | 2 + .../Features/Jobs/SeekerTests.cs | 403 ++++++++++++++++++ .../Features/Jobs/Seeker.cs | 55 ++- 3 files changed, 453 insertions(+), 7 deletions(-) diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs index ca337fa4..71aef12c 100644 --- a/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs +++ b/code/backend/Cleanuparr.Domain/Entities/Arr/ArrQualityProfile.cs @@ -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; } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 176270e5..2982e5b7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -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()) @@ -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 capturedSearchItems = []; mockArrClient .SearchItemAsync(radarrInstance, Arg.Any()) @@ -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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + 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()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + + [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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + 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()) + .Returns(ci => + { + capturedSearchItem = ci.ArgAt(1); + return 100L; + }); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + 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()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .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(), + Arg.Any()); + } + + [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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + new SearchableMovie { Id = 1, Title = "Missing Movie", Status = "released", Monitored = true, HasFile = false, Tags = [] } + ])); + + mockArrClient + .SearchItemAsync(radarrInstance, Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _radarrClient.DidNotReceive() + .GetQualityProfilesAsync(Arg.Any()); + } + [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(), Arg.Any()) @@ -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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + 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(), 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()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _sonarrClient.Received().GetEpisodesAsync(Arg.Any(), 10); + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + + [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(); + + _fixture.ArrQueueReader + .ReadAllAsync(mockArrClient, Arg.Any()) + .Returns([]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns( + ToAsyncEnumerable([ + 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()) + .Returns(mockArrClient); + + SeekerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _sonarrClient.DidNotReceive() + .GetEpisodesAsync(Arg.Any(), 10); + await mockArrClient.DidNotReceive() + .SearchItemAsync(Arg.Any(), Arg.Any()); + } + [Fact] public async Task ExecuteAsync_Sonarr_MonitoredOnlyTrue_ExcludesUnmonitoredSeries() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 35dc48fe..fc4e7ea9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -534,12 +534,16 @@ public sealed class Seeker : IHandler // Load cached CF scores when custom format score filtering is enabled Dictionary? cfScores = null; + HashSet upgradesDisabledProfileIds = []; if (instanceConfig.UseCustomFormatScore) { cfScores = await _eventsContext.CustomFormatScoreEntries .AsNoTracking() .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Radarr) .ToDictionaryAsync(e => e.ExternalItemId); + + List 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 tagsById = tags.ToDictionary(t => t.Id, t => t.Label); HashSet skipTagSet = new(instanceConfig.SkipTags, StringComparer.InvariantCultureIgnoreCase); + HashSet upgradesDisabledProfileIds = []; + if (instanceConfig.UseCustomFormatScore) + { + List profiles = await _sonarrClient.GetQualityProfilesAsync(arrInstance); + upgradesDisabledProfileIds = profiles.Where(p => !p.UpgradeAllowed).Select(p => p.Id).ToHashSet(); + } + DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); List 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 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 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 reasons = []; if (missingCount > 0)