Skip to content

feat(slskd): variant matching, user-failure downrank, escalating backoff - #62

Merged
chodeus merged 7 commits into
mainfrom
feat/source-selection-improvements
Jul 22, 2026
Merged

feat(slskd): variant matching, user-failure downrank, escalating backoff#62
chodeus merged 7 commits into
mainfrom
feat/source-selection-improvements

Conversation

@chodeus

@chodeus chodeus commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Three source-selection improvements; the first two adapt ideas from lklynet/aurral's Soulseek client, re-shaped for slskd/Lidarr album semantics.

Variant-profile matching

RemixSignaturesConflict now compares a structured variant profile — live / acoustic / demo / extended / mono-vs-stereo / remix signature — between search target and candidate. A studio search no longer matches (Live), (Acoustic) or (Demos) folders, and an Extended Mix no longer satisfies a Radio Edit search. Live/acoustic/demo detection is scoped to qualifier zones (bracketed segments, live at/in/from phrases, trailing words), so titles that merely contain the word — "Live Forever" — never trip it. (Extended Edition) is an edition, not a variant, and stays matchable. Applies everywhere the remix guard already ran: release matching, pre-import tagging, and title-driven track matching.

Per-user failure memory

A user whose downloads failed in the last 24h ('File not shared.', remote cancel) now has ALL their releases downranked multiplicatively in CalculatePriority (×0.65 per failure, floored at ×0.18, never zeroed so a sole source stays usable). Previously only the failed release itself was skipped while the same user's other shares kept full rank.

Escalating retry backoff

The automatic-search skip for recently-failed releases escalates with the per-release failure count — 1h after the first failure, 6h after the second, 24h from the third — replacing the flat 24h window, so a transiently busy source retries within the hour while a dead share still sits out a day. Interactive searches remain unfiltered.

Deliberately NOT ported from aurral: the pre-grab minimum-file-size penalty (legitimate single low-bitrate files exist) and search-result caching (slskd owns the network legwork, and caching transient failures as negatives is a footgun).

README note: the Matching & Retry Behaviour section added in #61 describes the flat 24h window; one-line follow-up needed after both merge.

Tests

Variant-dimension conflict matrix (12 rows incl. the title-word guard and edition exclusions), qualifier-zone extraction, backoff escalation, and priority downrank bounds. 239 total, all passing.

Summary by CodeRabbit

  • New Features
    • Improved variant-aware matching (live/acoustic/demo/extended, mono/stereo) with variant targeting propagated through searches.
    • Escalating cooldown for recently failed sources and refined retry-backoff behavior.
    • Download prioritization now downranks folders using recent per-user failure penalties.
  • Bug Fixes
    • Prevents unsafe retry/reattachment reuse across “poisoned” terminal states.
    • More accurate title parsing/matching (variant/qualifier handling, digit-token constraints, featured-artist stripping) and safer remix conflict detection.
    • More reliable search lifecycle cleanup and polling/cancellation resilience.
  • Tests
    • Expanded coverage for variant parsing/conflicts, retry-backoff escalation, and priority/retry heuristics.
  • Documentation
    • Updated README cooldown guidance to the escalating schedule.

Audit remediation (second commit wave)

An adversarial multi-agent audit of this branch plus v1.8.1/v1.9.0 surfaced 21 findings; the confirmed ones are fixed here:

Variant-guard false-rejects (the guard was over-firing): artist names containing keyword words ("Lil Flip") no longer mark the whole folder as a remix; trailing qualifiers survive [FLAC]/(2017) suffixes; albums literally titled Live/Acoustic/Demos match their well-named folders; the qualifier is also read from the parent path component (Album (Live)\FLAC layouts). MusicBrainz SecondaryTypes now feed the guard asymmetrically: a Live/Demo/Remix-typed target forgives candidate-side decoration its own title hides ("Apple Music Live: …", "Alive 2007") while never demanding decoration and never loosening explicit string qualifiers.

Tagging fail-closed hardening: TokenSetRatio's subset-saturation can no longer launder unknown variants — 2+ leftover tokens reject the pair, differing digit tokens reject ("Part 1" ≠ "Part 12"), TokenSortRatio breaks saturated ties so exact titles win their slots, feat-tags are stripped before scoring, and the variant vocabulary gains sped up/slowed/nightcore/daycore/reverb/8D/mashup/cover-by plus missing plural and a-cappella spellings. Various Artists album tags no longer disable the fallback; Live/Remix/Demo-secondary singles skip it entirely.

Retry-id/re-attach coherence: any terminal history event poisons an id (imported/ignored included), restart re-attach prefers the newest non-poisoned grab, and salting evicts stale same-release attempts from tracking.

Search-lifecycle races: status-poll 5xx/timeouts now feed the failed-poll counter instead of aborting the tier chain into a spurious backoff; cancels retry once and always run the terminal wait; search records are deleted from a finally so parse failures don't leak them; the interactive slot is a ConcurrentDictionary; the gate queue-wait cap is sized to worst-case occupancy.

Failure-state accuracy: multi-album failures (one history row per album) dedupe by DownloadId; users are charged per bad release, not per retry; a 48h lookback keeps the 24h backoff tier from decaying early; the cache is static across Lidarr's transient parser instances.

Deliberately deferred: the native-blocklist repair chain (history-Data key casing + release Guid/InfoHash identity) — a separate PR after a live DB check, since it contradicts an in-repo comment and touches every protocol's blocklist.

Tests: 259 total, all passing.

Three source-selection improvements, the first two adapted from ideas in
lklynet/aurral's Soulseek client:

- Variant-profile matching: RemixSignaturesConflict now compares a
  structured profile (live / acoustic / demo / extended / mono-stereo /
  remix signature) between search and candidate, so a studio search no
  longer matches "(Live)", "(Acoustic)" or "(Demos)" folders and an
  Extended Mix no longer satisfies a Radio Edit. Live/acoustic/demo
  detection is scoped to qualifier zones (brackets, live-at-venue
  phrases, trailing words) so titles that merely contain the word
  ("Live Forever") are untouched. "(Extended Edition)" stays an
  edition, not a variant.
- Per-user failure memory: releases from a user whose downloads failed
  in the last 24h are downranked multiplicatively in CalculatePriority
  (never zeroed) — previously only the failed release itself was
  penalized while the same user's other shares kept full rank.
- Escalating retry backoff: the automatic-search skip for failed
  releases now scales with the failure count (1h → 6h → 24h) instead of
  a flat 24h, so a transiently busy source retries within the hour.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8309b6b4-5fe2-4bdf-b588-b0e7c09b03b3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR expands Soulseek retry handling with poisoned-history detection, escalating backoff, and per-user priority penalties. It also adds structured release-variant matching, propagates variant metadata through searches, improves title matching, and hardens search and download cleanup.

Changes

Failure handling and priority scoring

Layer / File(s) Summary
Retry policy and priority contract
src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs, src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs, README.md
Retry IDs skip poisoned terminal events, retry windows escalate by failure count, and recent user failures reduce folder priority.
Failure-state aggregation and download ownership
src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs, src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
Failure history produces failed-release and per-user state; poisoned grabs are handled as fallbacks and stale retry attempts are evicted.
Search lifecycle and failure-state wiring
src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs, src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs, src/Sleezer/Indexers/Soulseek/Search/Core/SearchContext.cs, src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
Search cleanup, cancellation, polling, concurrency, failure counts, and variant payload propagation are updated.
Failure behavior validation
tests/Sleezer.Tests/SlskdLiveAuditFollowupTests.cs
Tests cover poisoned retry events, backoff escalation, and user-failure priority penalties.

Title variant matching and tagging

Layer / File(s) Summary
Variant profile extraction and conflict detection
src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs, src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs, src/Sleezer/Core/PostProcessing/PreImportTagger.cs
Release qualifiers are extracted structurally, compared before remix fuzz matching, and informed by album secondary types.
Title matching and fallback behavior
src/Sleezer/Core/PostProcessing/TrackTitleMatcher.cs, src/Sleezer/Core/PostProcessing/PreImportTagger.cs
Matching adds normalized dual-score selection and digit constraints; fallback handling improves numeric titles, featured artists, artist filtering, and qualifier exclusions.
Variant search propagation and validation
src/Sleezer/Indexers/Soulseek/Search/Core/SearchContext.cs, src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs, src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs, tests/Sleezer.Tests/SlskdLiveAuditFollowupTests.cs
Secondary types are carried into search requests and result payloads, with coverage for variant conflicts and qualifier extraction.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SlskdRequestGenerator
  participant SlskdSearchData
  participant SlskdIndexerParser
  participant SlskdFolderData
  SlskdRequestGenerator->>SlskdSearchData: send TargetVariantTypes
  SlskdSearchData->>SlskdIndexerParser: return search results
  SlskdIndexerParser->>SlskdFolderData: assign RecentUserFailures
  SlskdFolderData->>SlskdFolderData: calculate penalized priority
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR’s three main changes: variant matching, user-failure downranking, and escalating retry backoff.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/source-selection-improvements

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs`:
- Around line 312-323: Update the failure aggregation loop around
_historyService.Since to resolve each history entry’s latest grab before
updating perRelease or perUser, then apply the same protocol/indexer filters
used by GetGrabCounts so foreign failures are skipped. Preserve existing retry
grouping and username counting for matching Soulseek entries, and add a
regression test proving failures from another protocol or indexer are ignored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6990aaf3-eb66-4be1-ab13-85f90b4854a1

📥 Commits

Reviewing files that changed from the base of the PR and between 19712d9 and ea88214.

📒 Files selected for processing (5)
  • src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
  • src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
  • src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs
  • src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs
  • tests/Sleezer.Tests/SlskdLiveAuditFollowupTests.cs

Comment thread src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs Outdated
chodeus added 5 commits July 22, 2026 02:55
…s (review)

Unfiltered DownloadFailed history let torrent/usenet failures feed the
per-user map — a foreign DownloadUrl's last path segment is not a
Soulseek username. Apply the same protocol/indexer scoping as
GetGrabCounts. (Regression test skipped: the parser needs the full
Lidarr service graph, which the pure-unit test project deliberately
excludes.)
… ids, poll/cancel races

Adversarial-audit follow-ups across tonight's changes:

Variant matching (false-REJECT class):
- A keyword confined to the artist prefix ("Lil Flip - Undaground
  Legend") is a name, not a qualifier — only the title zone counts.
- Trailing-qualifier detection now survives "[FLAC]"/"(2017)" suffixes
  and albums literally titled Live/Acoustic/Demos.
- MusicBrainz SecondaryTypes (Live/Demo/Remix) forgive candidate-side
  qualifiers the target's title hides ("Apple Music Live: ...",
  "Alive 2007") without ever demanding decoration; threaded from the
  search criteria through SlskdSearchData to matcher and tagger.
- Guard also checks the parent path component ("Album (Live)\FLAC").
- Vocabulary: instrumentals/a cappella spellings/sped up/slowed/
  nightcore/daycore/reverb/8d/mashup/cover-by.

Title-driven tagging:
- Fail closed on unknown multi-token qualifiers (2+ leftover tokens),
  differing digit tokens ("Part 1" vs "Part 12"), TokenSortRatio
  tie-break so exact matches beat token-subset saturation, feat-strip
  before scoring, numeric-only filenames kept as titles.
- Various Artists album tags no longer disable the fallback; foreign-
  artist exclusions are logged. Live/Remix/Demo-secondary singles skip
  the fallback entirely (plain track titles can't verify the variant).

Retry ids and re-attach:
- Any terminal history event poisons an id (imported/ignored too, not
  just failed); cap walk extended past -r9.
- Restart re-attach skips poisoned grabs (falls through to the newest
  clean retry grab) and salting evicts stale same-release attempts from
  tracking so they can't swallow transfer state.

Search lifecycle:
- Status-poll 5xx/timeouts feed the failed-poll counter instead of
  aborting the tier chain; abnormal wait aborts still cancel; cancel
  retries once on transient failure and always runs the terminal wait;
  gate queue-wait cap sized to worst-case occupancy.
- Search records are deleted from a finally block so parse failures
  no longer leak them; interactive slot moved to ConcurrentDictionary.

Failure state:
- History rows deduped by DownloadId (multi-album failures wrote one
  row per album), users charged per bad release not per retry, 48h
  lookback so the 24h backoff tier doesn't decay early, cache made
  static across Lidarr's transient parser instances.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs (1)

293-325: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Thread a CancellationToken through this search path. SlskdRequestGenerator has no token today, so the gate wait, retry delays, and HTTP calls keep running after cancellation and can hold the search slot longer than necessary. Pass a token from the caller and use it in WaitAsync, Task.Delay, and _client.ExecuteAsync(...).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs` around lines 293 -
325, Thread a CancellationToken from the caller through the relevant
SlskdRequestGenerator search-finalization method and its helpers, including the
shown cancellation/retry path. Apply it to the gate wait, both retry and
terminal Task.Delay calls, and HTTP requests such as _client.ExecuteAsync;
preserve existing retry, not-found, and completion behavior while allowing
cancellation to stop promptly.

Source: Path instructions

src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs (1)

106-117: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a null Username before the dictionary lookup.

recentlyFailedUsers.GetValueOrDefault(response.Username) throws ArgumentNullException if response.Username is null — unlike the HashSet.Contains calls a few lines above, Dictionary<TKey,TValue> lookups don't tolerate a null key. Since this runs inside the single try/catch wrapping the whole response, one folder with a missing/null username would blank out every result for that search response, not just that folder.

🛡️ Proposed fix
                             Token = response.Token,
                             FileCount = response.FileCount,
-                            RecentUserFailures = recentlyFailedUsers.GetValueOrDefault(response.Username)
+                            RecentUserFailures = string.IsNullOrEmpty(response.Username)
+                                ? 0
+                                : recentlyFailedUsers.GetValueOrDefault(response.Username)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs` around lines 106 - 117,
Guard the RecentUserFailures assignment in the SlskdFolderData initializer
within the response-parsing flow so recentlyFailedUsers.GetValueOrDefault is
never called with a null response.Username. Preserve the existing lookup for
non-null usernames and use the appropriate empty/default failure collection when
the username is null, allowing the current response processing to continue.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs`:
- Around line 241-249: Update the `_interactiveResults` access in the
surrounding search-id handling to use the same nullable definition pattern as
`GetRecentFailureState`, `GetGrabCounts`, and `GetQueuedCounts`. Safely obtain
`_indexer.Definition?.Id` and return from the fire-and-forget path when the
definition or ID is unavailable, before calling `AddOrUpdate`.

In `@src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs`:
- Around line 263-266: Update the catch block around the search polling flow to
handle failures from CancelInFlightSearchAsync separately: log any cleanup
exception without allowing it to replace the original polling exception, then
rethrow the original exception unchanged.
- Around line 420-432: Update the exception filter around GetSearchResultsAsync
so only transient HTTP statuses and network failures are converted to null
polls. Ensure authentication, permission, and other permanent errors are
rethrown instead of reaching the null-poll counter, preserving the existing
debug logging for genuinely transient failures.
- Around line 293-316: The cleanup flow surrounding GetSearchResultsAsync must
not proceed when polling returns an unconfirmed or unknown search state. Update
the terminal-wait logic to continue polling or leave the search record intact
for non-terminal responses, and only allow the subsequent cancellation/deletion
path after an explicit terminal state or confirmed 404; preserve the existing
handling for confirmed NotFound responses in the cancel loop.

---

Outside diff comments:
In `@src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs`:
- Around line 106-117: Guard the RecentUserFailures assignment in the
SlskdFolderData initializer within the response-parsing flow so
recentlyFailedUsers.GetValueOrDefault is never called with a null
response.Username. Preserve the existing lookup for non-null usernames and use
the appropriate empty/default failure collection when the username is null,
allowing the current response processing to continue.

In `@src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs`:
- Around line 293-325: Thread a CancellationToken from the caller through the
relevant SlskdRequestGenerator search-finalization method and its helpers,
including the shown cancellation/retry path. Apply it to the gate wait, both
retry and terminal Task.Delay calls, and HTTP requests such as
_client.ExecuteAsync; preserve existing retry, not-found, and completion
behavior while allowing cancellation to stop promptly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 228126d5-9205-4c41-967c-c1d138f03ac3

📥 Commits

Reviewing files that changed from the base of the PR and between b7418a1 and e84ed58.

📒 Files selected for processing (12)
  • README.md
  • src/Sleezer/Core/PostProcessing/PreImportTagger.cs
  • src/Sleezer/Core/PostProcessing/TrackTitleMatcher.cs
  • src/Sleezer/Download/Clients/Soulseek/Models/SlskdDownloadItem.cs
  • src/Sleezer/Download/Clients/Soulseek/SlskdDownloadManager.cs
  • src/Sleezer/Indexers/Soulseek/Search/Core/SearchContext.cs
  • src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
  • src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs
  • src/Sleezer/Indexers/Soulseek/SlskdItemsParser.cs
  • src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs
  • src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs
  • tests/Sleezer.Tests/SlskdLiveAuditFollowupTests.cs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Sleezer/Indexers/Soulseek/SlsdkRecords.cs
  • src/Sleezer/Indexers/Soulseek/SlskdTextProcessor.cs

Comment thread src/Sleezer/Indexers/Soulseek/SlskdIndexerParser.cs
Comment thread src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs
Comment thread src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs
Comment thread src/Sleezer/Indexers/Soulseek/SlskdRequestGenerator.cs
…ation, terminal-confirmed delete

- Search-status poll now only swallows TRANSIENT failures (5xx/408/429/
  network); auth/permission/permanent errors (401/403/4xx) propagate so a
  bad API key fails the search instead of reading as empty.
- Abnormal-wait cleanup cancel is wrapped so a cleanup exception can't mask
  the original poll failure.
- ExecuteRemovalAsync confirms terminal state (or 404) before DELETE, so a
  still-InProgress search is left for a later pass instead of being deleted
  mid-flight into the finalize race.
- Null-guard _indexer.Definition?.Id in the interactive-slot Task.Run,
  matching the nullable handling used everywhere else.
@chodeus

chodeus commented Jul 22, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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