Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,15 @@ private async Task UnsetAllDefaultsAsync()
}
}

public async Task<QualityScore> ScoreSearchResult(SearchResult searchResult, QualityProfile profile)
public Task<QualityScore> ScoreSearchResult(SearchResult searchResult, QualityProfile profile) =>
ScoreSearchResult(searchResult, profile, resolvedIndexers: null);

private async Task<QualityScore> ScoreSearchResult(
SearchResult searchResult,
QualityProfile profile,
IReadOnlyDictionary<int, Indexer>? 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
Expand Down Expand Up @@ -287,7 +293,15 @@ private int GetQualityScore(string? quality)

public async Task<List<QualityScore>> ScoreSearchResults(List<SearchResult> 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
Expand All @@ -296,6 +310,43 @@ public async Task<List<QualityScore>> ScoreSearchResults(List<SearchResult> sear
.ToList();
}

private async Task<IReadOnlyDictionary<int, Indexer>> ResolveIndexersAsync(
List<SearchResult> searchResults)
{
var resolved = new Dictionary<int, Indexer>();
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;
}

/// <summary>
/// Checks if a quality string contains VBR preset indicators (v0, v1, v2).
/// </summary>
Expand Down
100 changes: 100 additions & 0 deletions listenarr.application/Search/Scoring/SearchResultScorer.Helpers.cs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

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<string>? 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<string>? 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<string, string>
{
{ "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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
using System.Globalization;

namespace Listenarr.Application.Search.Scoring
{
public partial class SearchResultScorer
{
/// <summary>
/// Parses an indexer's published date into a UTC instant.
/// </summary>
/// <remarks>
/// A bare <see cref="DateTime.TryParse(string, out DateTime)"/> converts an offset-bearing
/// string to the host's local time and hands back <see cref="DateTimeKind.Local"/>. The
/// scorer then subtracts that from <see cref="DateTime.UtcNow"/>, 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.
/// </remarks>
/// <param name="publishedDate">The raw value from the indexer, which may be null or empty.</param>
/// <param name="publishedUtc">The parsed instant, with Kind set to Utc.</param>
/// <returns>True if the value parsed; false if it was absent or unrecognised.</returns>
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);
}
}
}
Loading