Skip to content

fix(search): resolve indexers once per batch, read MinimumAge/MaximumSize, and fix a profile size overflow - #921

Open
m4bard wants to merge 7 commits into
Listenarrs:canaryfrom
m4bard:fix/899-indexer-age-and-size
Open

m4bard wants to merge 7 commits into
Listenarrs:canaryfrom
m4bard:fix/899-indexer-age-and-size

Conversation

@m4bard

@m4bard m4bard commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR now carries three fixes that all land in SearchResultScorer.cs and its partials, previously filed as three separate PRs: this one (#921), #863, and #985. #921 already said it would absorb #863 ("This conflicts with my own #863 in SearchResultScorer.cs... Whichever of the two lands first, I will rebase the other"), and #985 was carved out of #921's own work ("Found while wiring Indexer.MaximumSize in #921 and kept out of that PR as a separate bug"). Consolidating avoids two rebases of the same file for the same author, and #863 and #985 are being closed in favor of the commits here rather than abandoned; see the closing comments on each.

Change 1 (from #863): resolve indexers once per batch, not once per result in parallel

QualityProfileService.ScoreSearchResults fanned out with Task.WhenAll, and each task queried the same scoped IIndexerRepository, so a batch of N results issued N concurrent queries against one scoped DbContext. EF rejects a second operation on a context while another is in flight.

The scorer already caught that exception and logged it at Debug, so nothing failed outright. What it lost was indexerRetention, which stayed 0, and the branch that sets isNzb from the indexer's own type. Age and retention checks then scored the result against assumptions that did not match its indexer, and the batch came back plausibly ordered and quietly wrong.

ScoreSearchResults now resolves the batch's distinct indexers sequentially up front (ResolveIndexersAsync) and passes the lookup into SearchResultScorer, which prefers it over querying when present. The single-result ScoreSearchResult path is unchanged and still queries directly, since there is nothing to batch there. Three callers reach the batch path: the quality profile controller, download submission, and the six-hour automatic search sweep.

This is fewer queries as well as safer ones: a batch commonly spans many results across a handful of indexers, so it goes from one query per result to one per distinct indexer. IndexerSearchWorkflow already resolves its indexers before its own fan-out, so this brings the two in line.

Tested by counting the overlap directly rather than racing the EF exception: a stub IIndexerRepository records the highest number of calls it ever had in flight at once. It reports 12 without the change and 1 with it, over twelve results across three indexers, and pins the call count at 3 so a regression that restores per-result querying fails even if it happens not to overlap on a given run.

Change 2 (from #921): read Indexer.MinimumAge and Indexer.MaximumSize, and measure age in UTC

Fixes two of the three fields in #899, and leaves the third alone on purpose. Indexer.MinimumAge and Indexer.MaximumSize saved and reloaded 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 sits outside the existing size block, which is skipped for Usenet and reads QualityProfile.MaximumSize, a different setting with the same name. MinimumAge rejects a Usenet post that has not been up long enough, since grabbing too early tends to produce an incomplete or failed download; it does not apply to torrents, where a fresh post is complete.

Two things fell out of doing this. First, the indexer lookup moved above the size gate, since all three settings need it; that also fixes an ordering bug where a Usenet result recognized only by its indexer type was size-checked before the type correction that exempts it ran. Second, age was measured in the wrong timezone: the published date went through a bare DateTime.TryParse, which turns a trailing Z into the host's local time with Kind=Local, then got subtracted from DateTime.UtcNow. Every age was off by the server's UTC offset. It is parsed to UTC explicitly now, with AssumeUniversal for indexer dates carrying no offset.

SearchResultScorer.cs was at 498 lines against the architecture tests' 500-line cap, so the new UTC parse (TryParsePublishedDateUtc) and the title/profile detection helpers moved into SearchResultScorer.Helpers.cs and SearchResultScorer.PublishedDate.cs, both partials of the same class. No behavior changed in the move; the helpers moved unedited and the parse does exactly what the inline expression did.

Indexer.EnableRss is untouched. It advertises RSS sync, and there is no RSS sync anywhere in the codebase to enable; wiring it would mean building the feature, and removing it would presume the intent is to drop it. That is a question rather than a fix, raised separately.

Six tests added, each with an opposite case so none of them can pass against an implementation that always rejects. Reverting the UTC parse, the size gate, or the age gate each fails only its own test and leaves the rest green.

Change 3 (from #985): 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 before comparing against a result's long byte size. At 2048 MB the multiply overflows int and wraps to a negative number, so the two gates fail in opposite directions: Maximum rejects every release, since everything is larger than a negative ceiling, and Minimum stops rejecting anything, since nothing is smaller than a negative floor. The settings form puts no upper bound on either field, and a 2 GB ceiling on an audiobook profile is ordinary.

The fix is the same (long) cast the indexer size gate a few lines above already uses, applied to both profile lines. Two tests added, each with a control row below the overflow threshold, so a change that simply stopped rejecting or simply started rejecting fails the control.

How the three fit together

All three touch SearchResultScorer.cs directly; #921 also touches its two partials, #863 also touches QualityProfileService.cs. The indexer-resolution change (#863) and the age/size/UTC change (#921) hit the same lines for different reasons, which is the conflict #921's original body flagged; #985's cast fix sits in the untouched profile size block a few lines below and does not interact with either. Nothing about combining them changes behavior beyond what each already documented on its own; there is no fourth interaction to call out.

Testing

Full backend suite run twice: on this PR's own branch before folding #863 and #985 in, and again after. Before: 3132 passed, 0 failed, 130 skipped. After: 3140 passed, 0 failed, 130 skipped. The 8 new passes are the concurrency test from #863 and the two overflow tests from #985; nothing else moved.

Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.

…sure 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) <noreply@anthropic.com>
@m4bard
m4bard requested a review from a team September 1, 2026 03:04
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 1, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 1, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 1, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 1, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 2, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 3, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 3, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 3, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 4, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 4, 2026
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)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 9, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 10, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 10, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 10, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 10, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard added a commit to m4bard/Listenarr that referenced this pull request Sep 10, 2026
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)
(cherry picked from commit d4026e72e7260482bfe09865cc1721239f062e2f)
m4bard and others added 3 commits September 11, 2026 12:39
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
m4bard and others added 3 commits September 22, 2026 08:23
… 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.
…corer

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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
@m4bard m4bard changed the title fix(search): read Indexer.MinimumAge and Indexer.MaximumSize, and measure age in UTC fix(search): resolve indexers once per batch, read MinimumAge/MaximumSize, and fix a profile size overflow Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant