Skip to content

Commit 8d6263f

Browse files
m4bardclaude
andcommitted
stack: split the indexer context out of SearchResultScorer
LOCAL ONLY. Not part of Listenarrs#863 or Listenarrs#921 and deliberately not offered to either. Both PRs grow SearchResultScorer.cs. Listenarrs#863 adds the batch-resolved indexer dictionary, Listenarrs#921 hoists the indexer lookup above the size gate and reads two more fields from it. Canary is 457 lines, Listenarrs#863 alone 476, Listenarrs#921 alone 498, and the two together 517. ActiveProductionSourceFiles_RemainFocused caps production files at 500, so each passes alone and only the combination fails. This moves the indexer lookup and IsNzbResult into a partial, which takes the main file to 473. It is a stack commit rather than a change to either pull request because the reason it exists is how our local stack combines them, which is not visible from either PR and not the maintainer's problem to review. Putting it in Listenarrs#921 would also mean hand-porting Listenarrs#863's resolved-indexer logic into a file Listenarrs#863 knows nothing about on every rebuild, since rerere can only replay a resolution for a conflicted hunk, not an edit to a new file. If both PRs merge upstream, canary itself crosses the cap and this becomes a real follow-up rather than a local patch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YEVQ7qDJLk5196MFeggWuA (cherry picked from commit f58964137bfb6a44650de793d541e17f87e87a7c)
1 parent 58348fa commit 8d6263f

2 files changed

Lines changed: 106 additions & 52 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Listenarr - Audiobook Management System
3+
* Copyright (C) 2024-2026 Listenarr Contributors
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published
7+
* by the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
using Microsoft.Extensions.Logging;
20+
21+
namespace Listenarr.Application.Search.Scoring
22+
{
23+
/// <summary>
24+
/// What the indexer contributes to scoring a result: its retention, its own size ceiling, its
25+
/// minimum age, and whether it tells us the result is Usenet when the result itself did not.
26+
///
27+
/// LOCAL ONLY. This split exists because #863 and #921 each grow SearchResultScorer.cs and
28+
/// together push it past the 500 line cap ActiveProductionSourceFiles_RemainFocused enforces.
29+
/// Each passes that test alone; only the combination fails. It is deliberately not part of
30+
/// either pull request, so neither is distorted by how our local stack happens to combine them.
31+
/// </summary>
32+
public partial class SearchResultScorer
33+
{
34+
private readonly record struct IndexerContext(
35+
bool IsNzb,
36+
int RetentionDays,
37+
int MaximumSizeMb,
38+
int MinimumAgeMinutes);
39+
40+
/// <summary>
41+
/// Read the indexer once, before the size and age gates, because all three depend on it.
42+
/// This is also where isNzb is corrected from the indexer's own type.
43+
/// </summary>
44+
private async Task<IndexerContext> ResolveIndexerContextAsync(SearchResult searchResult, bool isNzb)
45+
{
46+
var retention = 0;
47+
var maximumSizeMb = 0;
48+
var minimumAgeMinutes = 0;
49+
if (searchResult.IndexerId.HasValue
50+
&& (_resolvedIndexers != null || _indexerRepository != null))
51+
{
52+
try
53+
{
54+
var idx = _resolvedIndexers != null
55+
? (_resolvedIndexers.TryGetValue(searchResult.IndexerId.Value, out var preresolved)
56+
? preresolved
57+
: null)
58+
: await _indexerRepository!.GetByIdAsync(searchResult.IndexerId.Value);
59+
if (idx != null)
60+
{
61+
retention = idx.Retention;
62+
maximumSizeMb = idx.MaximumSize;
63+
minimumAgeMinutes = idx.MinimumAge;
64+
if (!isNzb && !string.IsNullOrWhiteSpace(idx.Type) && string.Equals(idx.Type, "Usenet", StringComparison.OrdinalIgnoreCase))
65+
{
66+
isNzb = true;
67+
_logger.LogDebug("Indexer {IndexerId} type '{Type}' detected as Usenet; applying NZB/Usenet exemptions", searchResult.IndexerId.Value, idx.Type);
68+
}
69+
}
70+
}
71+
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
72+
{
73+
_logger.LogDebug(ex, "Failed to fetch indexer settings for IndexerId {Id}", searchResult.IndexerId.Value);
74+
}
75+
}
76+
77+
return new IndexerContext(isNzb, retention, maximumSizeMb, minimumAgeMinutes);
78+
}
79+
80+
private static bool IsNzbResult(SearchResult r)
81+
{
82+
bool hasNzbUrl = !string.IsNullOrEmpty(r.NzbUrl);
83+
bool isNzbType = string.Equals(r.DownloadType, "nzb", StringComparison.OrdinalIgnoreCase)
84+
|| string.Equals(r.DownloadType, "usenet", StringComparison.OrdinalIgnoreCase);
85+
bool indexerIndicatesNzb = !string.IsNullOrEmpty(r.IndexerImplementation)
86+
&& (r.IndexerImplementation.IndexOf("nzb", StringComparison.OrdinalIgnoreCase) >= 0
87+
|| r.IndexerImplementation.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0);
88+
bool sourceIndicatesNzb = !string.IsNullOrEmpty(r.Source)
89+
&& r.Source.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0;
90+
bool urlIndicatesNzb = !string.IsNullOrEmpty(r.ResultUrl)
91+
&& (r.ResultUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase)
92+
|| r.ResultUrl.IndexOf("/nzb", StringComparison.OrdinalIgnoreCase) >= 0);
93+
bool torrentIndicatesNzb = !string.IsNullOrEmpty(r.TorrentUrl)
94+
&& r.TorrentUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase);
95+
return hasNzbUrl || isNzbType || indexerIndicatesNzb || sourceIndicatesNzb || urlIndicatesNzb || torrentIndicatesNzb;
96+
}
97+
}
98+
}

listenarr.application/Search/Scoring/SearchResultScorer.cs

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
namespace Listenarr.Application.Search.Scoring
2323
{
24-
public class SearchResultScorer
24+
public partial class SearchResultScorer
2525
{
2626
private readonly IIndexerRepository? _indexerRepository;
2727
private readonly ILogger _logger;
@@ -105,40 +105,13 @@ public async Task<QualityScore> Score(SearchResult searchResult, QualityProfile
105105
// Detect NZB/Usenet more broadly
106106
var isNzb = IsNzbResult(searchResult);
107107

108-
// The indexer is read before the size and age gates because all three depend on it.
109-
// It also corrects isNzb from the indexer's own type, and that correction used to
110-
// happen after the size gate had already run, so a Usenet result recognised only by
111-
// its indexer type was size-checked despite the exemption just below.
112-
int indexerRetention = 0;
113-
int indexerMaximumSizeMb = 0;
114-
int indexerMinimumAgeMinutes = 0;
115-
if (searchResult.IndexerId.HasValue
116-
&& (_resolvedIndexers != null || _indexerRepository != null))
117-
{
118-
try
119-
{
120-
var idx = _resolvedIndexers != null
121-
? (_resolvedIndexers.TryGetValue(searchResult.IndexerId.Value, out var preresolved)
122-
? preresolved
123-
: null)
124-
: await _indexerRepository!.GetByIdAsync(searchResult.IndexerId.Value);
125-
if (idx != null)
126-
{
127-
indexerRetention = idx.Retention;
128-
indexerMaximumSizeMb = idx.MaximumSize;
129-
indexerMinimumAgeMinutes = idx.MinimumAge;
130-
if (!isNzb && !string.IsNullOrWhiteSpace(idx.Type) && string.Equals(idx.Type, "Usenet", StringComparison.OrdinalIgnoreCase))
131-
{
132-
isNzb = true;
133-
_logger.LogDebug("Indexer {IndexerId} type '{Type}' detected as Usenet; applying NZB/Usenet exemptions", searchResult.IndexerId.Value, idx.Type);
134-
}
135-
}
136-
}
137-
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
138-
{
139-
_logger.LogDebug(ex, "Failed to fetch indexer settings for IndexerId {Id}", searchResult.IndexerId.Value);
140-
}
141-
}
108+
// Everything the indexer contributes, resolved once. Lives in
109+
// SearchResultScorer.IndexerContext.cs; see there for why it happens before the gates.
110+
var indexerContext = await ResolveIndexerContextAsync(searchResult, isNzb);
111+
isNzb = indexerContext.IsNzb;
112+
var indexerRetention = indexerContext.RetentionDays;
113+
var indexerMaximumSizeMb = indexerContext.MaximumSizeMb;
114+
var indexerMinimumAgeMinutes = indexerContext.MinimumAgeMinutes;
142115

143116
if (indexerMaximumSizeMb > 0 && searchResult.Size > (long)indexerMaximumSizeMb * 1024 * 1024)
144117
{
@@ -496,22 +469,5 @@ private int GetQualityScore(string quality)
496469
private static bool ContainsVbrPreset(string qualityLower, string preset) => qualityLower.Contains(preset) || qualityLower.Contains($"-{preset}") || qualityLower.Contains($" {preset}");
497470
private static bool ContainsAnyBitrate(string qualityLower, params string[] bitrates) => bitrates.Any(b => qualityLower.Contains(b));
498471

499-
private static bool IsNzbResult(SearchResult r)
500-
{
501-
bool hasNzbUrl = !string.IsNullOrEmpty(r.NzbUrl);
502-
bool isNzbType = string.Equals(r.DownloadType, "nzb", StringComparison.OrdinalIgnoreCase)
503-
|| string.Equals(r.DownloadType, "usenet", StringComparison.OrdinalIgnoreCase);
504-
bool indexerIndicatesNzb = !string.IsNullOrEmpty(r.IndexerImplementation)
505-
&& (r.IndexerImplementation.IndexOf("nzb", StringComparison.OrdinalIgnoreCase) >= 0
506-
|| r.IndexerImplementation.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0);
507-
bool sourceIndicatesNzb = !string.IsNullOrEmpty(r.Source)
508-
&& r.Source.IndexOf("usenet", StringComparison.OrdinalIgnoreCase) >= 0;
509-
bool urlIndicatesNzb = !string.IsNullOrEmpty(r.ResultUrl)
510-
&& (r.ResultUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase)
511-
|| r.ResultUrl.IndexOf("/nzb", StringComparison.OrdinalIgnoreCase) >= 0);
512-
bool torrentIndicatesNzb = !string.IsNullOrEmpty(r.TorrentUrl)
513-
&& r.TorrentUrl.EndsWith(".nzb", StringComparison.OrdinalIgnoreCase);
514-
return hasNzbUrl || isNzbType || indexerIndicatesNzb || sourceIndicatesNzb || urlIndicatesNzb || torrentIndicatesNzb;
515-
}
516472
}
517473
}

0 commit comments

Comments
 (0)