From 525d250930446d45b2a9cf9d5e84d65a93fd3272 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:24:49 -0500 Subject: [PATCH 1/7] fix(search): read Indexer.MinimumAge and Indexer.MaximumSize, and measure age in UTC Indexer.MinimumAge and Indexer.MaximumSize save and reload and were read by nothing. Both are now applied in SearchResultScorer, beside Retention, which was already read there. MaximumSize rejects a result larger than the indexer's own ceiling. It is deliberately outside the existing size block, which is skipped for Usenet and reads QualityProfile.MaximumSize. That is a different setting with the same name, which is worth saying out loud because grepping MaximumSize finds the profile one and makes the indexer field look wired. MinimumAge rejects a Usenet post that has not been up long enough. The reason is propagation rather than preference: grabbing too early produces an incomplete or failed download. It does not apply to torrents, where a fresh post is complete. Two things fell out of doing this. The indexer lookup moved above the size gate, because all three settings need it. That also fixes an ordering bug: the lookup is what corrects isNzb from the indexer's own type, and it used to run after the size gate, so a Usenet result recognised only by its indexer type was size-checked despite the exemption immediately below it. Age was measured in the wrong timezone. The published date went through a bare DateTime.TryParse, which converts a trailing Z to the host's local time and returns Kind=Local, and the result was then subtracted from DateTime.UtcNow. Every age was out by the server's UTC offset, so results looked older west of UTC and newer east of it. At day granularity that usually only matters at a boundary; at the minute granularity MinimumAge needs, it decides the answer. It is parsed to UTC explicitly now, with AssumeUniversal for indexer dates that carry no offset. There is a test for it that does not depend on where it runs. Indexer.EnableRss is the third field in the report and is untouched. It advertises RSS sync, and there is no RSS sync anywhere in the codebase to enable, so wiring it would mean building the feature and removing it would presume you do not intend to. That one is a question rather than a fix, and I would rather ask it separately. Controls: reverting the UTC parse, the size gate or the age gate each fails its own test and leaves the others green. Co-Authored-By: Claude Opus 5 (1M context) --- .../Search/Scoring/SearchResultScorer.cs | 81 ++++++--- .../Quality/QualityProfileScoringTests.cs | 166 ++++++++++++++++++ 2 files changed, 227 insertions(+), 20 deletions(-) diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.cs b/listenarr.application/Search/Scoring/SearchResultScorer.cs index bfb4461435..53c2427ea3 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -17,6 +17,8 @@ */ using Microsoft.Extensions.Logging; +using System.Globalization; + namespace Listenarr.Application.Search.Scoring { public class SearchResultScorer @@ -89,6 +91,43 @@ public async Task Score(SearchResult searchResult, QualityProfile // Detect NZB/Usenet more broadly var isNzb = IsNzbResult(searchResult); + // The indexer is read before the size and age gates because all three depend on it. + // It also corrects isNzb from the indexer's own type, and that correction used to + // happen after the size gate had already run, so a Usenet result recognised only by + // its indexer type was size-checked despite the exemption just below. + int indexerRetention = 0; + int indexerMaximumSizeMb = 0; + int indexerMinimumAgeMinutes = 0; + if (searchResult.IndexerId.HasValue && _indexerRepository != null) + { + try + { + var idx = await _indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); + if (idx != null) + { + indexerRetention = idx.Retention; + indexerMaximumSizeMb = idx.MaximumSize; + indexerMinimumAgeMinutes = idx.MinimumAge; + if (!isNzb && !string.IsNullOrWhiteSpace(idx.Type) && string.Equals(idx.Type, "Usenet", StringComparison.OrdinalIgnoreCase)) + { + isNzb = true; + _logger.LogDebug("Indexer {IndexerId} type '{Type}' detected as Usenet; applying NZB/Usenet exemptions", searchResult.IndexerId.Value, idx.Type); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogDebug(ex, "Failed to fetch indexer settings for IndexerId {Id}", searchResult.IndexerId.Value); + } + } + + if (indexerMaximumSizeMb > 0 && searchResult.Size > (long)indexerMaximumSizeMb * 1024 * 1024) + { + score.RejectionReasons.Add($"File too large for indexer (> {indexerMaximumSizeMb} MB)"); + score.TotalScore = -1; + return score; + } + // Size checks (skip for NZB) if (!isNzb && searchResult.Size > 0) { @@ -115,33 +154,35 @@ public async Task Score(SearchResult searchResult, QualityProfile return score; } - // Age checks and indexer retention double ageDays = 0; - int indexerRetention = 0; - if (searchResult.IndexerId.HasValue && _indexerRepository != null) + + // Parsed to UTC explicitly. A bare TryParse converts a trailing Z to the host's local + // time and returns Kind=Local, and this then subtracts it from DateTime.UtcNow, so + // every age was out by the server's UTC offset: results looked older west of UTC and + // newer east of it. AssumeUniversal covers indexer dates that carry no offset at all. + if (!string.IsNullOrEmpty(searchResult.PublishedDate) + && DateTime.TryParse( + searchResult.PublishedDate, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var publishDate)) { - try + ageDays = (DateTime.UtcNow - publishDate).TotalDays; + + // Usenet only, and the reason is propagation rather than preference: a post that + // has not finished propagating downloads as an incomplete or failed grab. Sonarr + // and Radarr expose the same per-indexer minimum for the same reason. + if (isNzb && indexerMinimumAgeMinutes > 0) { - var idx = await _indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); - if (idx != null) + var ageMinutes = (DateTime.UtcNow - publishDate).TotalMinutes; + if (ageMinutes < indexerMinimumAgeMinutes) { - indexerRetention = idx.Retention; - if (!isNzb && !string.IsNullOrWhiteSpace(idx.Type) && string.Equals(idx.Type, "Usenet", StringComparison.OrdinalIgnoreCase)) - { - isNzb = true; - _logger.LogDebug("Indexer {IndexerId} type '{Type}' detected as Usenet; applying NZB/Usenet exemptions", searchResult.IndexerId.Value, idx.Type); - } + score.RejectionReasons.Add($"Too new ({(int)ageMinutes} minutes < indexer minimum age {indexerMinimumAgeMinutes} minutes)"); + score.TotalScore = -1; + return score; } } - catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) - { - _logger.LogDebug(ex, "Failed to fetch indexer retention for IndexerId {Id}", searchResult.IndexerId.Value); - } - } - if (!string.IsNullOrEmpty(searchResult.PublishedDate) && DateTime.TryParse(searchResult.PublishedDate, out var publishDate)) - { - ageDays = (DateTime.UtcNow - publishDate).TotalDays; if (isNzb) { if (indexerRetention > 0 && ageDays > indexerRetention) diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs index dcfb50b5a6..06f0737902 100644 --- a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs @@ -491,6 +491,172 @@ public async Task Age_Rejection_Applied_When_Age_Exceeds_IndexerRetention() Assert.True(score.TotalScore < 0, "Result should be rejected for age exceeding indexer retention"); } + [Fact] + public async Task Age_Is_Measured_In_Utc_Whatever_Offset_The_Indexer_Sends() + { + // The published date here is ten minutes old, written with a +09:00 offset. A bare + // DateTime.TryParse converts it to the host's local time and hands back Kind=Local, + // which is then subtracted from DateTime.UtcNow, so the age came out wrong by the + // difference between the two offsets. That made every age check depend on where the + // server was, and for a minutes-scale check like this one it decides the outcome. + var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var db = new ListenArrDbContext(options); + var indexer = new Listenarr.Domain.Search.Indexer + { + Name = "OffsetIndexer", + Url = "https://offset.local", + MinimumAge = 120, + Retention = 3650, + IsEnabled = true + }; + db.Indexers.Add(indexer); + db.SaveChanges(); + + var service = new QualityProfileService(new QualityProfileRepository(db), NullLogger.Instance, new EfIndexerRepository(db)); + var profile = new QualityProfile { MaximumAge = 3650, MinimumSeeders = 0 }; + + var tenMinutesAgoInTokyo = DateTimeOffset.UtcNow + .AddMinutes(-10) + .ToOffset(TimeSpan.FromHours(9)) + .ToString("o"); + + var result = new SearchResult + { + Title = "Fresh Post From Another Timezone", + PublishedDate = tenMinutesAgoInTokyo, + DownloadType = "nzb", + IndexerId = indexer.Id + }; + + var score = await service.ScoreSearchResult(result, profile); + + // Ten minutes is under the two hour minimum however it is written down. + Assert.Contains(score.RejectionReasons, reason => reason.Contains("Too new", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(200, true)] + [InlineData(0, false)] + public async Task Indexer_MaximumSize_Rejects_Results_Over_The_Limit(int indexerMaximumSize, bool expectRejection) + { + // Indexer.MaximumSize had no reader. The scorer's existing size gate reads + // QualityProfile.MaximumSize, which shadows it by name. The second case is the + // control: with the indexer limit unset, the same result has to pass. + var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var db = new ListenArrDbContext(options); + var indexer = new Listenarr.Domain.Search.Indexer + { + Name = "SizeCappedIndexer", + Url = "https://size.local", + MaximumSize = indexerMaximumSize, + IsEnabled = true + }; + db.Indexers.Add(indexer); + db.SaveChanges(); + + var service = new QualityProfileService(new QualityProfileRepository(db), NullLogger.Instance, new EfIndexerRepository(db)); + var profile = new QualityProfile { MinimumSeeders = 0, MaximumAge = 3650 }; + + var result = new SearchResult + { + Title = "Large Result", + PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o"), + DownloadType = "torrent", + Size = 300L * 1024 * 1024, + Seeders = 10, + IndexerId = indexer.Id + }; + + var score = await service.ScoreSearchResult(result, profile); + + if (expectRejection) + { + Assert.Contains(score.RejectionReasons, reason => reason.Contains("too large for indexer", StringComparison.OrdinalIgnoreCase)); + Assert.True(score.TotalScore < 0); + } + else + { + Assert.DoesNotContain(score.RejectionReasons, reason => reason.Contains("too large for indexer", StringComparison.OrdinalIgnoreCase)); + } + } + + [Theory] + [InlineData(120, true)] + [InlineData(0, false)] + public async Task Indexer_MinimumAge_Rejects_Nzbs_That_Have_Not_Propagated(int minimumAgeMinutes, bool expectRejection) + { + // Indexer.MinimumAge had no reader either. It exists so a post that has not finished + // propagating is not grabbed as an incomplete download. The second case is the control. + var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var db = new ListenArrDbContext(options); + var indexer = new Listenarr.Domain.Search.Indexer + { + Name = "PropagationIndexer", + Url = "https://usenet.local", + MinimumAge = minimumAgeMinutes, + Retention = 3650, + IsEnabled = true + }; + db.Indexers.Add(indexer); + db.SaveChanges(); + + var service = new QualityProfileService(new QualityProfileRepository(db), NullLogger.Instance, new EfIndexerRepository(db)); + var profile = new QualityProfile { MaximumAge = 3650, MinimumSeeders = 0 }; + + var result = new SearchResult + { + Title = "Fresh Post", + PublishedDate = DateTime.UtcNow.AddMinutes(-10).ToString("o"), + DownloadType = "nzb", + IndexerId = indexer.Id + }; + + var score = await service.ScoreSearchResult(result, profile); + + if (expectRejection) + { + Assert.Contains(score.RejectionReasons, reason => reason.Contains("Too new", StringComparison.Ordinal)); + Assert.True(score.TotalScore < 0); + } + else + { + Assert.DoesNotContain(score.RejectionReasons, reason => reason.Contains("Too new", StringComparison.Ordinal)); + } + } + + [Fact] + public async Task Indexer_MinimumAge_Does_Not_Apply_To_Torrents() + { + // Propagation is a Usenet concern. A torrent that has just been posted is complete. + var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + using var db = new ListenArrDbContext(options); + var indexer = new Listenarr.Domain.Search.Indexer + { + Name = "TorrentMinAge", + Url = "https://torrent.local", + MinimumAge = 120, + Type = "Torrent", + IsEnabled = true + }; + db.Indexers.Add(indexer); + db.SaveChanges(); + + var service = new QualityProfileService(new QualityProfileRepository(db), NullLogger.Instance, new EfIndexerRepository(db)); + var profile = new QualityProfile { MaximumAge = 3650, MinimumSeeders = 0 }; + + var result = new SearchResult + { + Title = "Fresh Torrent", + PublishedDate = DateTime.UtcNow.AddMinutes(-10).ToString("o"), + DownloadType = "torrent", + Seeders = 10, + IndexerId = indexer.Id + }; + + var score = await service.ScoreSearchResult(result, profile); + Assert.DoesNotContain(score.RejectionReasons, reason => reason.Contains("Too new", StringComparison.Ordinal)); + } + [Fact] public async Task Torrent_Age_Rejection_Uses_ProfileMaxAge() { From 87240a525ee81005490d3f15168dcaad19325b83 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:39:39 -0500 Subject: [PATCH 2/7] refactor(search): split the scorer, and test the UTC date parse directly The published-date test this PR added cannot fail on a host whose local time is UTC, which is every hosted CI runner. Measured rather than argued: with the DateTimeStyles reverted to a bare TryParse, the whole of QualityProfileScoringTests passes under TZ=UTC and the offset test fails under TZ=America/Chicago. The bug is TryParse returning the host's local time with Kind=Local, and on a UTC host the local time and the UTC time are the same instant, so an assertion about the instant cannot see it. The Kind differs on every host. So the parse moves into an internal TryParsePublishedDateUtc that a test can call, and SearchResultScorerPublishedDateTests asserts both the instant and the Kind for an offset string, a Z string and a bare one. With the styles reverted those three rows fail under TZ=UTC with Local, Local and Unspecified. SearchResultScorer.cs was at 498 lines against the 500-line cap the architecture tests enforce, so there was nowhere to put the method. The class becomes partial and the title and profile detection helpers move to SearchResultScorer.Helpers.cs, which leaves the main file at 413 lines and the new parse in a file of its own. No behaviour changes: the helpers move unedited, and the parse does exactly what the inline expression did. Co-Authored-By: Claude Opus 5 (1M context) --- .../Scoring/SearchResultScorer.Helpers.cs | 100 ++++++++++++++++++ .../SearchResultScorer.PublishedDate.cs | 62 +++++++++++ .../Search/Scoring/SearchResultScorer.cs | 93 +--------------- .../SearchResultScorerPublishedDateTests.cs | 61 +++++++++++ 4 files changed, 227 insertions(+), 89 deletions(-) create mode 100644 listenarr.application/Search/Scoring/SearchResultScorer.Helpers.cs create mode 100644 listenarr.application/Search/Scoring/SearchResultScorer.PublishedDate.cs create mode 100644 tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.Helpers.cs b/listenarr.application/Search/Scoring/SearchResultScorer.Helpers.cs new file mode 100644 index 0000000000..8ca880d684 --- /dev/null +++ b/listenarr.application/Search/Scoring/SearchResultScorer.Helpers.cs @@ -0,0 +1,100 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Application.Search.Scoring +{ + // The scoring helpers live here so SearchResultScorer.cs stays clear of the 500-line + // architecture cap. They are pure title and profile inspection with no state of their own. + public partial class SearchResultScorer + { + private static bool HasPreferredLanguages(QualityProfile profile) => profile.PreferredLanguages != null && profile.PreferredLanguages.Count > 0; + private static bool HasPreferredFormats(QualityProfile profile) => profile.PreferredFormats != null && profile.PreferredFormats.Count > 0; + + private static string? DetectFormatFromTitle(string titleLower, List? preferredFormats) + { + if (preferredFormats == null || preferredFormats.Count == 0 || string.IsNullOrEmpty(titleLower)) return null; + return preferredFormats + .Where(format => !string.IsNullOrWhiteSpace(format)) + .Select(format => format.ToLower().Trim()) + .FirstOrDefault(token => titleLower.Contains(token) || titleLower.Contains("[" + token + "]") || titleLower.Contains("(" + token + ")") || titleLower.Contains("." + token)); + } + + private static string? DetectLanguageFromTitle(string titleLower, List? preferredLanguages) + { + if (preferredLanguages == null || preferredLanguages.Count == 0 || string.IsNullOrEmpty(titleLower)) return null; + foreach (var lang in preferredLanguages.Where(language => !string.IsNullOrWhiteSpace(language))) + { + var token = lang.ToLower().Trim(); + if (titleLower.Contains(token) || titleLower.Contains("[" + token + "]") || titleLower.Contains("(" + token + ")") || titleLower.Contains(" " + token + " ")) + { + return lang; + } + } + var common = new Dictionary + { + { "eng", "English" }, { "english", "English" }, { "es", "Spanish" }, { "spanish", "Spanish" }, + { "de", "German" }, { "german", "German" }, { "fr", "French" }, { "french", "French" } + }; + foreach (var (token, name) in common) if (titleLower.Contains(token)) return name; + return null; + } + + private int GetQualityScore(string quality) + { + if (string.IsNullOrEmpty(quality)) return 0; + var lowerQuality = quality.ToLower(); + if (lowerQuality.Contains("flac")) return 100; + if (lowerQuality.Contains("aax")) return 95; + if (lowerQuality.Contains("m4b")) return 90; + if (lowerQuality.Contains("opus")) return 85; + if (ContainsVbrPreset(lowerQuality, "v0")) return 82; + if (ContainsVbrPreset(lowerQuality, "v1")) return 76; + if (ContainsVbrPreset(lowerQuality, "v2")) return 70; + if (lowerQuality.Contains("aac") || lowerQuality.Contains("m4a")) return 78; + if (lowerQuality.Contains("320")) return 80; + if (lowerQuality.Contains("256")) return 74; + if (lowerQuality.Contains("192")) return 60; + if (lowerQuality.Contains("vbr") || lowerQuality.Contains("cbr")) return 65; + if (lowerQuality.Contains("mp3") && !ContainsAnyBitrate(lowerQuality, "64", "128", "192", "256", "320")) return 65; + if (lowerQuality.Contains("128")) return 50; + if (lowerQuality.Contains("64")) return 40; + return 0; + } + + private static bool ContainsVbrPreset(string qualityLower, string preset) => qualityLower.Contains(preset) || qualityLower.Contains($"-{preset}") || qualityLower.Contains($" {preset}"); + private static bool ContainsAnyBitrate(string qualityLower, params string[] bitrates) => bitrates.Any(b => qualityLower.Contains(b)); + + private static bool IsNzbResult(SearchResult r) + { + bool hasNzbUrl = !string.IsNullOrEmpty(r.NzbUrl); + bool isNzbType = string.Equals(r.DownloadType, "nzb", StringComparison.OrdinalIgnoreCase) + || string.Equals(r.DownloadType, "usenet", StringComparison.OrdinalIgnoreCase); + bool indexerIndicatesNzb = !string.IsNullOrEmpty(r.IndexerImplementation) + && (r.IndexerImplementation.IndexOf("nzb", StringComparison.OrdinalIgnoreCase) >= 0 + || r.IndexerImplementation.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0); + bool sourceIndicatesNzb = !string.IsNullOrEmpty(r.Source) + && r.Source.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0; + bool urlIndicatesNzb = !string.IsNullOrEmpty(r.ResultUrl) + && (r.ResultUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) + || r.ResultUrl.IndexOf("/nzb", StringComparison.OrdinalIgnoreCase) >= 0); + bool torrentIndicatesNzb = !string.IsNullOrEmpty(r.TorrentUrl) + && r.TorrentUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase); + return hasNzbUrl || isNzbType || indexerIndicatesNzb || sourceIndicatesNzb || urlIndicatesNzb || torrentIndicatesNzb; + } + } +} diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.PublishedDate.cs b/listenarr.application/Search/Scoring/SearchResultScorer.PublishedDate.cs new file mode 100644 index 0000000000..964be56d45 --- /dev/null +++ b/listenarr.application/Search/Scoring/SearchResultScorer.PublishedDate.cs @@ -0,0 +1,62 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Globalization; + +namespace Listenarr.Application.Search.Scoring +{ + public partial class SearchResultScorer + { + /// + /// Parses an indexer's published date into a UTC instant. + /// + /// + /// A bare converts an offset-bearing + /// string to the host's local time and hands back . The + /// scorer then subtracts that from , so every age came out + /// wrong by the server's UTC offset: results looked older west of UTC and newer east of + /// it. At day granularity that only shows at a boundary; at the minute granularity the + /// indexer's minimum age needs, it decides the answer. + /// + /// AdjustToUniversal converts an offset to UTC rather than to local time, and + /// AssumeUniversal covers indexer dates that carry no offset at all, which is the safer + /// reading of a feed whose timestamps are conventionally UTC. + /// + /// This is a separate method so it can be asserted on directly. A test that drives the + /// scorer end to end cannot see the difference on a host whose local time is UTC, which + /// is every hosted CI runner: there the old code and the new code return the same + /// instant. The Kind does differ on every host, so that is what a test should pin. + /// + /// The raw value from the indexer, which may be null or empty. + /// The parsed instant, with Kind set to Utc. + /// True if the value parsed; false if it was absent or unrecognised. + internal static bool TryParsePublishedDateUtc(string? publishedDate, out DateTime publishedUtc) + { + publishedUtc = default; + if (string.IsNullOrEmpty(publishedDate)) + { + return false; + } + + return DateTime.TryParse( + publishedDate, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out publishedUtc); + } + } +} diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.cs b/listenarr.application/Search/Scoring/SearchResultScorer.cs index 53c2427ea3..24bf777dd2 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -17,11 +17,9 @@ */ using Microsoft.Extensions.Logging; -using System.Globalization; - namespace Listenarr.Application.Search.Scoring { - public class SearchResultScorer + public partial class SearchResultScorer { private readonly IIndexerRepository? _indexerRepository; private readonly ILogger _logger; @@ -156,16 +154,9 @@ public async Task Score(SearchResult searchResult, QualityProfile double ageDays = 0; - // Parsed to UTC explicitly. A bare TryParse converts a trailing Z to the host's local - // time and returns Kind=Local, and this then subtracts it from DateTime.UtcNow, so - // every age was out by the server's UTC offset: results looked older west of UTC and - // newer east of it. AssumeUniversal covers indexer dates that carry no offset at all. - if (!string.IsNullOrEmpty(searchResult.PublishedDate) - && DateTime.TryParse( - searchResult.PublishedDate, - CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, - out var publishDate)) + // Parsed to UTC explicitly, in TryParsePublishedDateUtc, so the subtraction from + // DateTime.UtcNow below is between two UTC instants whatever the host's offset is. + if (TryParsePublishedDateUtc(searchResult.PublishedDate, out var publishDate)) { ageDays = (DateTime.UtcNow - publishDate).TotalDays; @@ -418,81 +409,5 @@ public async Task Score(SearchResult searchResult, QualityProfile return score; } - - // Helpers (copied/adapted from old service) - private static bool HasPreferredLanguages(QualityProfile profile) => profile.PreferredLanguages != null && profile.PreferredLanguages.Count > 0; - private static bool HasPreferredFormats(QualityProfile profile) => profile.PreferredFormats != null && profile.PreferredFormats.Count > 0; - - private static string? DetectFormatFromTitle(string titleLower, List? preferredFormats) - { - if (preferredFormats == null || preferredFormats.Count == 0 || string.IsNullOrEmpty(titleLower)) return null; - return preferredFormats - .Where(format => !string.IsNullOrWhiteSpace(format)) - .Select(format => format.ToLower().Trim()) - .FirstOrDefault(token => titleLower.Contains(token) || titleLower.Contains("[" + token + "]") || titleLower.Contains("(" + token + ")") || titleLower.Contains("." + token)); - } - - private static string? DetectLanguageFromTitle(string titleLower, List? preferredLanguages) - { - if (preferredLanguages == null || preferredLanguages.Count == 0 || string.IsNullOrEmpty(titleLower)) return null; - foreach (var lang in preferredLanguages.Where(language => !string.IsNullOrWhiteSpace(language))) - { - var token = lang.ToLower().Trim(); - if (titleLower.Contains(token) || titleLower.Contains("[" + token + "]") || titleLower.Contains("(" + token + ")") || titleLower.Contains(" " + token + " ")) - { - return lang; - } - } - var common = new Dictionary - { - { "eng", "English" }, { "english", "English" }, { "es", "Spanish" }, { "spanish", "Spanish" }, - { "de", "German" }, { "german", "German" }, { "fr", "French" }, { "french", "French" } - }; - foreach (var (token, name) in common) if (titleLower.Contains(token)) return name; - return null; - } - - private int GetQualityScore(string quality) - { - if (string.IsNullOrEmpty(quality)) return 0; - var lowerQuality = quality.ToLower(); - if (lowerQuality.Contains("flac")) return 100; - if (lowerQuality.Contains("aax")) return 95; - if (lowerQuality.Contains("m4b")) return 90; - if (lowerQuality.Contains("opus")) return 85; - if (ContainsVbrPreset(lowerQuality, "v0")) return 82; - if (ContainsVbrPreset(lowerQuality, "v1")) return 76; - if (ContainsVbrPreset(lowerQuality, "v2")) return 70; - if (lowerQuality.Contains("aac") || lowerQuality.Contains("m4a")) return 78; - if (lowerQuality.Contains("320")) return 80; - if (lowerQuality.Contains("256")) return 74; - if (lowerQuality.Contains("192")) return 60; - if (lowerQuality.Contains("vbr") || lowerQuality.Contains("cbr")) return 65; - if (lowerQuality.Contains("mp3") && !ContainsAnyBitrate(lowerQuality, "64", "128", "192", "256", "320")) return 65; - if (lowerQuality.Contains("128")) return 50; - if (lowerQuality.Contains("64")) return 40; - return 0; - } - - private static bool ContainsVbrPreset(string qualityLower, string preset) => qualityLower.Contains(preset) || qualityLower.Contains($"-{preset}") || qualityLower.Contains($" {preset}"); - private static bool ContainsAnyBitrate(string qualityLower, params string[] bitrates) => bitrates.Any(b => qualityLower.Contains(b)); - - private static bool IsNzbResult(SearchResult r) - { - bool hasNzbUrl = !string.IsNullOrEmpty(r.NzbUrl); - bool isNzbType = string.Equals(r.DownloadType, "nzb", StringComparison.OrdinalIgnoreCase) - || string.Equals(r.DownloadType, "usenet", StringComparison.OrdinalIgnoreCase); - bool indexerIndicatesNzb = !string.IsNullOrEmpty(r.IndexerImplementation) - && (r.IndexerImplementation.IndexOf("nzb", StringComparison.OrdinalIgnoreCase) >= 0 - || r.IndexerImplementation.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0); - bool sourceIndicatesNzb = !string.IsNullOrEmpty(r.Source) - && r.Source.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0; - bool urlIndicatesNzb = !string.IsNullOrEmpty(r.ResultUrl) - && (r.ResultUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase) - || r.ResultUrl.IndexOf("/nzb", StringComparison.OrdinalIgnoreCase) >= 0); - bool torrentIndicatesNzb = !string.IsNullOrEmpty(r.TorrentUrl) - && r.TorrentUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase); - return hasNzbUrl || isNzbType || indexerIndicatesNzb || sourceIndicatesNzb || urlIndicatesNzb || torrentIndicatesNzb; - } } } diff --git a/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs b/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs new file mode 100644 index 0000000000..f007a264fb --- /dev/null +++ b/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs @@ -0,0 +1,61 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +namespace Listenarr.Tests.Features.Application.Audiobooks.Quality +{ + /// + /// Direct tests for the published-date parse the scorer's age gates depend on. + /// + /// + /// These assert on the parse rather than on a score, because a test that drives the whole + /// scorer cannot tell the fix from the bug on a host whose local time is UTC. The bug was + /// DateTime.TryParse returning the host's local time with Kind=Local, and on a UTC host the + /// local time and the UTC time are the same instant. CI runs on UTC images, so a test that + /// only checks the instant would pass with the fix reverted. The Kind differs on every host, + /// which is why it is asserted here alongside the instant. + /// + public class SearchResultScorerPublishedDateTests + { + [Theory] + [InlineData("2026-03-01T09:00:00+09:00")] + [InlineData("2026-03-01T00:00:00Z")] + [InlineData("2026-03-01T00:00:00")] + public void TryParsePublishedDateUtc_Returns_The_Same_Instant_Marked_Utc(string published) + { + // All three name the same moment: an offset that has to be subtracted, an explicit + // Z, and a bare local-looking string an indexer sends meaning UTC. + var parsed = SearchResultScorer.TryParsePublishedDateUtc(published, out var publishedUtc); + + Assert.True(parsed); + Assert.Equal(DateTimeKind.Utc, publishedUtc.Kind); + Assert.Equal(new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), publishedUtc); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not a date")] + public void TryParsePublishedDateUtc_Returns_False_When_There_Is_Nothing_To_Read(string? published) + { + var parsed = SearchResultScorer.TryParsePublishedDateUtc(published, out var publishedUtc); + + Assert.False(parsed); + Assert.Equal(default, publishedUtc); + } + } +} From a54fe488b495c6454fb6e47a584d0ef7d38008e5 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:39:39 -0500 Subject: [PATCH 3/7] test(search): pin that the indexer size ceiling applies to Usenet too The indexer's Maximum Size gate sits above the block that skips size checks for Usenet, so unlike the profile setting of the same name it applies to both protocols. That is deliberate and it matches how Sonarr and Radarr treat their equivalent, but the only test rows were torrents and the inline comment beside the gate argues the other way about the profile setting, so nothing stopped a later reader moving the gate inside the !isNzb block and calling it a tidy-up. The size theory gains a download-type parameter and two nzb rows. Control: adding !isNzb to the gate fails the new rejection row and leaves the three others green. Co-Authored-By: Claude Opus 5 (1M context) --- .../Quality/QualityProfileScoringTests.cs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs index 06f0737902..8ed3cbbfbf 100644 --- a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs @@ -535,13 +535,22 @@ public async Task Age_Is_Measured_In_Utc_Whatever_Offset_The_Indexer_Sends() } [Theory] - [InlineData(200, true)] - [InlineData(0, false)] - public async Task Indexer_MaximumSize_Rejects_Results_Over_The_Limit(int indexerMaximumSize, bool expectRejection) + [InlineData(200, "torrent", true)] + [InlineData(0, "torrent", false)] + [InlineData(200, "nzb", true)] + [InlineData(0, "nzb", false)] + public async Task Indexer_MaximumSize_Rejects_Results_Over_The_Limit(int indexerMaximumSize, string downloadType, bool expectRejection) { // Indexer.MaximumSize had no reader. The scorer's existing size gate reads - // QualityProfile.MaximumSize, which shadows it by name. The second case is the - // control: with the indexer limit unset, the same result has to pass. + // QualityProfile.MaximumSize, which shadows it by name. The rows with the indexer + // limit unset are the control: the same result has to pass. + // + // The nzb rows pin the deliberate difference from the profile's setting of the same + // name. The profile size gate is skipped for Usenet, because a Usenet grab is not + // sized the way a torrent is; the indexer's ceiling is a property of the indexer and + // applies to both protocols, which is the shape Sonarr and Radarr use for their + // equivalent. Without these rows nothing stops someone moving the gate inside the + // !isNzb block and calling it a tidy-up. var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; using var db = new ListenArrDbContext(options); var indexer = new Listenarr.Domain.Search.Indexer @@ -561,7 +570,7 @@ public async Task Indexer_MaximumSize_Rejects_Results_Over_The_Limit(int indexer { Title = "Large Result", PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o"), - DownloadType = "torrent", + DownloadType = downloadType, Size = 300L * 1024 * 1024, Seeders = 10, IndexerId = indexer.Id From 4540e62b2c944423fde97576ab149bf9b217a51f Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:48:04 -0500 Subject: [PATCH 4/7] test(search): make the new parse tests follow the repository conventions BackendArchitectureTests.TestClasses_FollowRepositoryConventions requires every test class to inherit BaseTests and carry an exact Name trait and a non-empty Category trait. The new class had none of the three, which the focused filter could not show and the full suite did. Co-Authored-By: Claude Opus 5 (1M context) --- .../Quality/SearchResultScorerPublishedDateTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs b/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs index f007a264fb..8fff761f65 100644 --- a/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/SearchResultScorerPublishedDateTests.cs @@ -15,6 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +using Listenarr.Tests.Common; namespace Listenarr.Tests.Features.Application.Audiobooks.Quality { @@ -29,7 +30,9 @@ namespace Listenarr.Tests.Features.Application.Audiobooks.Quality /// only checks the instant would pass with the fix reverted. The Kind differs on every host, /// which is why it is asserted here alongside the instant. /// - public class SearchResultScorerPublishedDateTests + [Trait("Name", "SearchResultScorerPublishedDateTests")] + [Trait("Category", "Scoring")] + public class SearchResultScorerPublishedDateTests : BaseTests { [Theory] [InlineData("2026-03-01T09:00:00+09:00")] From 3fddf4680f163c9513a94bb437446960a7c86b89 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:18:46 -0500 Subject: [PATCH 5/7] fix(scoring): resolve indexers once per batch, not once per result in parallel ScoreSearchResults fans out with Task.WhenAll, and each task built a SearchResultScorer over the same scoped IIndexerRepository and queried it for the result's indexer. IIndexerRepository is scoped and the ListenArrDbContext behind it is scoped, so a batch of N results issued N concurrent queries against one context. EF rejects a second operation started on a context while another is in flight. The symptom is not a failed request. The scorer catches the exception and logs at Debug, leaving indexerRetention at 0 and skipping the Usenet detection that sets isNzb. So age and retention checks silently score against the wrong assumptions for whichever results lost the race, and the batch comes back plausibly ordered and quietly wrong. Resolve each distinct indexer once, sequentially, before fanning out, and pass the results into the scorer. Three callers reach this: the quality profile controller, download submission, and the six-hour automatic search sweep. This is also fewer queries rather than merely safer ones. A batch commonly carries many results across a handful of indexers, so it goes from one query per result to one per distinct indexer. IndexerSearchWorkflow already fetches its indexers before its own fan-out, so this makes the two agree. The single-result ScoreSearchResult keeps its old behaviour and still queries, since there is nothing to batch there. Asserting on the EF exception would mean racing it, so the test counts the overlap directly: a stub repository records the highest number of calls in flight at once. It reports 12 without the change and 1 with it, and also pins the query count at one per distinct indexer rather than one per result. --- .../Quality/QualityProfileService.cs | 57 ++++++++- .../Search/Scoring/SearchResultScorer.cs | 23 +++- .../QualityProfileScoringConcurrencyTests.cs | 111 ++++++++++++++++++ 3 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs diff --git a/listenarr.application/Audiobooks/Quality/QualityProfileService.cs b/listenarr.application/Audiobooks/Quality/QualityProfileService.cs index 3097a50605..eaab906c0f 100644 --- a/listenarr.application/Audiobooks/Quality/QualityProfileService.cs +++ b/listenarr.application/Audiobooks/Quality/QualityProfileService.cs @@ -190,9 +190,15 @@ private async Task UnsetAllDefaultsAsync() } } - public async Task ScoreSearchResult(SearchResult searchResult, QualityProfile profile) + public Task ScoreSearchResult(SearchResult searchResult, QualityProfile profile) => + ScoreSearchResult(searchResult, profile, resolvedIndexers: null); + + private async Task ScoreSearchResult( + SearchResult searchResult, + QualityProfile profile, + IReadOnlyDictionary? resolvedIndexers) { - var scorer = new SearchResultScorer(_indexerRepository, _logger); + var scorer = new SearchResultScorer(_indexerRepository, _logger, resolvedIndexers); var score = await scorer.Score(searchResult, profile); // Also calculate the Prowlarr-style composite (Smart) score so the UI @@ -287,7 +293,15 @@ private int GetQualityScore(string? quality) public async Task> ScoreSearchResults(List searchResults, QualityProfile profile) { - var scores = await Task.WhenAll(searchResults.Select(result => ScoreSearchResult(result, profile))); + // Resolve every indexer this batch refers to before fanning out, not inside it. + // Scoring runs in parallel and the scorer reads indexer retention per result, so a + // per-result lookup meant N concurrent queries against one scoped DbContext. EF + // rejects the overlap, the scorer catches it and logs at Debug, and the result keeps + // a retention of 0 with Usenet detection skipped. The scores come out quietly wrong + // rather than the request failing. + var resolvedIndexers = await ResolveIndexersAsync(searchResults); + var scores = await Task.WhenAll( + searchResults.Select(result => ScoreSearchResult(result, profile, resolvedIndexers))); // Ensure rejected results are ordered last regardless of numeric TotalScore return scores @@ -296,6 +310,43 @@ public async Task> ScoreSearchResults(List sear .ToList(); } + private async Task> ResolveIndexersAsync( + List searchResults) + { + var resolved = new Dictionary(); + if (_indexerRepository == null) + { + return resolved; + } + + // Sequential and de-duplicated: a batch usually refers to a handful of indexers even + // when it carries hundreds of results, so this is fewer queries than before as well as + // non-overlapping ones. + foreach (var indexerId in searchResults + .Where(result => result.IndexerId.HasValue) + .Select(result => result.IndexerId!.Value) + .Distinct()) + { + try + { + var indexer = await _indexerRepository.GetByIdAsync(indexerId); + if (indexer != null) + { + resolved[indexerId] = indexer; + } + } + catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException) + { + _logger.LogWarning( + ex, + "Failed to resolve indexer {IndexerId} while scoring a search batch; retention and Usenet detection will be skipped for its results", + indexerId); + } + } + + return resolved; + } + /// /// Checks if a quality string contains VBR preset indicators (v0, v1, v2). /// diff --git a/listenarr.application/Search/Scoring/SearchResultScorer.cs b/listenarr.application/Search/Scoring/SearchResultScorer.cs index 24bf777dd2..e1e1d17316 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -34,10 +34,24 @@ public partial class SearchResultScorer public int QualityNotAllowedPenalty { get; set; } = -20; public int ForbiddenWordRejectionFlag { get; set; } = -1; // sentinel for rejection + private readonly IReadOnlyDictionary? _resolvedIndexers; + public SearchResultScorer(IIndexerRepository? indexerRepository, ILogger logger) + : this(indexerRepository, logger, resolvedIndexers: null) + { + } + + // resolvedIndexers lets a caller scoring a whole batch resolve each indexer once up front + // and pass the results in. The repository is scoped, and so is the DbContext behind it, so + // results scored in parallel must not each run their own lookup. + public SearchResultScorer( + IIndexerRepository? indexerRepository, + ILogger logger, + IReadOnlyDictionary? resolvedIndexers) { _indexerRepository = indexerRepository; _logger = logger; + _resolvedIndexers = resolvedIndexers; } public async Task Score(SearchResult searchResult, QualityProfile profile) @@ -96,11 +110,16 @@ public async Task Score(SearchResult searchResult, QualityProfile int indexerRetention = 0; int indexerMaximumSizeMb = 0; int indexerMinimumAgeMinutes = 0; - if (searchResult.IndexerId.HasValue && _indexerRepository != null) + if (searchResult.IndexerId.HasValue + && (_resolvedIndexers != null || _indexerRepository != null)) { try { - var idx = await _indexerRepository.GetByIdAsync(searchResult.IndexerId.Value); + var idx = _resolvedIndexers != null + ? (_resolvedIndexers.TryGetValue(searchResult.IndexerId.Value, out var preresolved) + ? preresolved + : null) + : await _indexerRepository!.GetByIdAsync(searchResult.IndexerId.Value); if (idx != null) { indexerRetention = idx.Retention; diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs new file mode 100644 index 0000000000..087e54e46c --- /dev/null +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs @@ -0,0 +1,111 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Tests.Common; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Quality +{ + [Trait("Name", "QualityProfileScoringConcurrencyTests")] + [Trait("Category", "Unit")] + public class QualityProfileScoringConcurrencyTests : BaseTests + { + // IIndexerRepository is registered scoped and the ListenArrDbContext behind it is scoped + // too, so every repository in a scope shares one context. EF rejects a second operation + // started on a context while another is in flight. Asserting on the real exception would + // mean racing it, so this counts overlap directly: the stub records the highest number of + // calls it ever had in flight at once. Anything above one is the condition EF refuses. + private sealed class OverlapRecordingIndexerRepository : IIndexerRepository + { + private int _inFlight; + public int MaxConcurrent { get; private set; } + public int CallCount { get; private set; } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + var now = Interlocked.Increment(ref _inFlight); + lock (this) + { + CallCount++; + if (now > MaxConcurrent) MaxConcurrent = now; + } + + // A real query is not instantaneous. Without this the tasks can complete one at a + // time by luck and the overlap the test exists to catch would go unobserved. + await Task.Delay(20); + + Interlocked.Decrement(ref _inFlight); + return new Indexer { Id = id, Name = $"indexer-{id}", Type = "Usenet", Retention = 1500 }; + } + + public Task> GetAllAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + public Task> GetEnabledAsync(bool isAutomaticSearch, CancellationToken ct = default) => + Task.FromResult(new List()); + public Task GetByNameAsync(string name, CancellationToken ct = default) => + Task.FromResult(null); + public Task AddAsync(Indexer indexer, CancellationToken ct = default) => + Task.FromResult(indexer); + public Task UpdateAsync(Indexer indexer, CancellationToken ct = default) => Task.CompletedTask; + public Task DeleteAsync(int id, CancellationToken ct = default) => Task.CompletedTask; + } + + [Fact] + public async Task ScoreSearchResults_DoesNotQueryTheIndexerRepositoryConcurrently() + { + var indexerRepository = new OverlapRecordingIndexerRepository(); + var service = new QualityProfileService( + Mock.Of(), + NullLogger.Instance, + indexerRepository); + + // Twelve results across three indexers: enough to overlap, and enough to show that the + // batch does not need one query per result. + var searchResults = Enumerable.Range(0, 12) + .Select(i => new SearchResult + { + Id = $"result-{i}", + Title = $"A Book {i}", + IndexerId = (i % 3) + 1, + Format = "mp3", + Language = "English", + PublishedDate = DateTime.UtcNow.AddDays(-1).ToString("o") + }) + .ToList(); + + var profile = new QualityProfile + { + MinimumSize = 0, + MaximumSize = 0, + PreferredFormats = ["mp3"], + PreferredWords = [], + MustNotContain = [], + MustContain = [], + PreferredLanguages = ["English"], + MinimumSeeders = 0, + MaximumAge = 3650 + }; + + var scores = await service.ScoreSearchResults(searchResults, profile); + + Assert.Equal(searchResults.Count, scores.Count); + Assert.Equal(1, indexerRepository.MaxConcurrent); + // Three distinct indexers, so three lookups rather than one per result. + Assert.Equal(3, indexerRepository.CallCount); + } + } +} From 713f152d6611b54fb49f700c85b97a6dac69ebc3 Mon Sep 17 00:00:00 2001 From: m4bard <304653687+m4bard@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:43:46 -0500 Subject: [PATCH 6/7] test(scoring): assert the pre-resolved indexer actually reaches the scorer The concurrency test asserts the batch makes three non-overlapping lookups for twelve results. Both of those are properties of the lookup, not of the use. Pass an empty dictionary to the scorer instead of the resolved one and the test still sees three non-overlapping calls and still passes, while every result silently loses its indexer retention and its Usenet detection. That is the same quietly wrong scoring the change exists to prevent, so the regression the test would have to catch is the one it cannot see. The new case gives the stub a ten day retention and a result published a hundred days ago, with the profile's MaximumAge set well beyond that so nothing else in the scorer can produce the rejection. An ignored or empty dictionary leaves retention at zero and the result accepted. No production code changed. Co-Authored-By: Claude Fable 5.1 --- .../QualityProfileScoringConcurrencyTests.cs | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs index 087e54e46c..e91d252642 100644 --- a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringConcurrencyTests.cs @@ -29,7 +29,7 @@ public class QualityProfileScoringConcurrencyTests : BaseTests // started on a context while another is in flight. Asserting on the real exception would // mean racing it, so this counts overlap directly: the stub records the highest number of // calls it ever had in flight at once. Anything above one is the condition EF refuses. - private sealed class OverlapRecordingIndexerRepository : IIndexerRepository + private sealed class OverlapRecordingIndexerRepository(int retention = 1500) : IIndexerRepository { private int _inFlight; public int MaxConcurrent { get; private set; } @@ -49,7 +49,7 @@ private sealed class OverlapRecordingIndexerRepository : IIndexerRepository await Task.Delay(20); Interlocked.Decrement(ref _inFlight); - return new Indexer { Id = id, Name = $"indexer-{id}", Type = "Usenet", Retention = 1500 }; + return new Indexer { Id = id, Name = $"indexer-{id}", Type = "Usenet", Retention = retention }; } public Task> GetAllAsync(CancellationToken ct = default) => @@ -107,5 +107,54 @@ public async Task ScoreSearchResults_DoesNotQueryTheIndexerRepositoryConcurrentl // Three distinct indexers, so three lookups rather than one per result. Assert.Equal(3, indexerRepository.CallCount); } + + // The overlap assertion above passes whether or not the resolved indexers reach the + // scorer: a batch that looks each indexer up once and then throws the answers away makes + // exactly the same three non-overlapping calls. This asks the other half of the question. + // Retention is 10 days and the result is 100 days old, so the indexer's own retention has + // to be the thing that rejects it. A profile MaximumAge well beyond 100 days means nothing + // else in the scorer can produce that rejection, and an empty or ignored dictionary leaves + // retention at 0 and the result accepted. + [Fact] + public async Task ScoreSearchResults_AppliesTheResolvedIndexerRetention() + { + var indexerRepository = new OverlapRecordingIndexerRepository(retention: 10); + var service = new QualityProfileService( + Mock.Of(), + NullLogger.Instance, + indexerRepository); + + var searchResults = new List + { + new() + { + Id = "aged-result", + Title = "A Book", + IndexerId = 7, + Format = "mp3", + Language = "English", + PublishedDate = DateTime.UtcNow.AddDays(-100).ToString("o") + } + }; + + var profile = new QualityProfile + { + MinimumSize = 0, + MaximumSize = 0, + PreferredFormats = ["mp3"], + PreferredWords = [], + MustNotContain = [], + MustContain = [], + PreferredLanguages = ["English"], + MinimumSeeders = 0, + MaximumAge = 3650 + }; + + var scores = await service.ScoreSearchResults(searchResults, profile); + + var score = Assert.Single(scores); + Assert.True(score.IsRejected); + Assert.Contains(score.RejectionReasons, reason => reason.Contains("indexer retention 10 days")); + } } } From 201220829e7426716abb93cb6aab663c9e95dc9d 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 7/7] 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 e1e1d17316..aa38bf7f00 100644 --- a/listenarr.application/Search/Scoring/SearchResultScorer.cs +++ b/listenarr.application/Search/Scoring/SearchResultScorer.cs @@ -148,13 +148,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 8ed3cbbfbf..bd90b7f28d 100644 --- a/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs +++ b/tests/Features/Application/Audiobooks/Quality/QualityProfileScoringTests.cs @@ -763,6 +763,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))); + } } }