From 5d3937f58eaa47ab50a97b9b5cdd917a427a4f7f Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:53:28 -0500 Subject: [PATCH] fix(search): compute the profile size gates in long, not int QualityProfile.MinimumSize and MaximumSize are int megabytes, and the scorer multiplied them by 1024 * 1024 in int arithmetic. 2048 MB is one byte past int.MaxValue once multiplied, so a profile capped at 2 GB or more wraps negative and the two gates fail in opposite directions: every release is "larger than" a negative maximum and is rejected as too large, and nothing is "smaller than" a negative minimum so the minimum silently stops applying. The quality profile form puts no upper bound on either input, and a 2 GB ceiling on an audiobook profile is an ordinary thing to set. The fix is the cast the indexer size gate one block above already uses. Both theories carry a control row below the overflow, so a fix that simply stopped rejecting, or simply started rejecting, fails them. Without the casts the four overflow rows fail and the two control rows pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../Search/Scoring/SearchResultScorer.cs | 8 +- .../Quality/QualityProfileScoringTests.cs | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.cs b/listenarr.application/Search/Scoring/SearchResultScorer.cs index bfb446143..d2717bfa1 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -92,13 +92,17 @@ public async Task Score(SearchResult searchResult, QualityProfile // Size checks (skip for NZB) if (!isNzb && searchResult.Size > 0) { - if (profile.MinimumSize > 0 && searchResult.Size < profile.MinimumSize * 1024 * 1024) + // (long) before the multiply, not after. MinimumSize and MaximumSize are int MB + // and the settings form puts no ceiling on either, so 2048 or more overflows int + // and wraps negative: the maximum gate then rejects every release as too large, + // and the minimum gate stops rejecting anything at all. + if (profile.MinimumSize > 0 && searchResult.Size < (long)profile.MinimumSize * 1024 * 1024) { score.RejectionReasons.Add($"File too small (< {profile.MinimumSize} MB)"); score.TotalScore = -1; return score; } - if (profile.MaximumSize > 0 && searchResult.Size > profile.MaximumSize * 1024 * 1024) + if (profile.MaximumSize > 0 && searchResult.Size > (long)profile.MaximumSize * 1024 * 1024) { score.RejectionReasons.Add($"File too large (> {profile.MaximumSize} MB)"); score.TotalScore = -1; diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs index dcfb50b5a..a1d16a75d 100644 --- a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs @@ -588,6 +588,80 @@ public async Task MinimumScore_Zero_ShouldAllow_AnyPositiveScore() Assert.False(score.IsRejected, "Result should not be rejected when MinimumScore = 0 and score > 0"); Assert.True(score.TotalScore > 0, "Score should be positive"); } + + [Theory] + [InlineData(2048, false)] + [InlineData(4096, false)] + [InlineData(1024, true)] + public async Task Profile_MaximumSize_SurvivesProfilesLargerThanTwoGigabytes(int maximumSizeMb, bool expectRejection) + { + // profile.MaximumSize * 1024 * 1024 was evaluated in int. MaximumSize is int MB and + // the quality profile form puts no upper bound on the input, so 2048 MB or more + // overflows to a negative number and every release is larger than it: the gate + // rejects everything. A 2 GB ceiling on an audiobook profile is not exotic. + // + // The 1024 row is the control. It is below the overflow, the same 1.5 GB result is + // genuinely over it, and it has to keep being rejected, so a fix that simply stopped + // rejecting fails here. + var service = CreateService(); + var profile = new QualityProfile + { + MinimumSeeders = 0, + MaximumAge = 3650, + MaximumSize = maximumSizeMb + }; + + var result = new SearchResult + { + Title = "Ordinary Torrent", + DownloadType = "torrent", + Size = 1536L * 1024 * 1024, + Seeders = 10, + PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o") + }; + + var score = await service.ScoreSearchResult(result, profile); + + Assert.Equal( + expectRejection, + score.RejectionReasons.Any(reason => reason.Contains("too large (", StringComparison.OrdinalIgnoreCase))); + } + + [Theory] + [InlineData(2048, true)] + [InlineData(4096, true)] + [InlineData(512, false)] + public async Task Profile_MinimumSize_SurvivesProfilesLargerThanTwoGigabytes(int minimumSizeMb, bool expectRejection) + { + // The same overflow on the other gate, failing the opposite way round: the comparison + // becomes "smaller than a negative number", which nothing is, so the minimum stops + // rejecting anything and the setting silently does nothing. + // + // The 512 row is the control: below the overflow the 1 GB result clears the minimum + // and must not be rejected. + var service = CreateService(); + var profile = new QualityProfile + { + MinimumSeeders = 0, + MaximumAge = 3650, + MinimumSize = minimumSizeMb + }; + + var result = new SearchResult + { + Title = "Ordinary Torrent", + DownloadType = "torrent", + Size = 1024L * 1024 * 1024, + Seeders = 10, + PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o") + }; + + var score = await service.ScoreSearchResult(result, profile); + + Assert.Equal( + expectRejection, + score.RejectionReasons.Any(reason => reason.Contains("too small (", StringComparison.OrdinalIgnoreCase))); + } } }