Skip to content

feat(indexer): send an indexer's grabs to a pinned download client (#3080) - #3283

Merged
vavallee merged 8 commits into
mainfrom
feat/3080-per-indexer-download-client
Oct 15, 2026
Merged

vavallee merged 8 commits into
mainfrom
feat/3080-per-indexer-download-client

Conversation

@vavallee

@vavallee vavallee commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Each indexer gets an optional Download client setting. Unset (the default) keeps today's routing unchanged. When set, every grab from that indexer (scheduled search, search page, Pending force grab, Retry download, bulk retry) goes to that client, and if the client cannot take it the grab fails with a message naming the indexer (and the client, for admins) and is never sent to another client. Closes #3080.

Implementation notes

  • Migration 109 (109_indexer_download_client.sql) adds indexers.download_client_id, nullable, REFERENCES download_clients(id) ON DELETE RESTRICT. Existing rows stay NULL. Number check: origin/main tops out at 107, open PR fix(series): cover split edition parts listed in another series by the same author (#3324) #3239 holds 108, fix(importer): restore Skipped status on undoing library adoption (#2885) #2928 holds 098 and Feature/download client media eligibility #2633 holds 096 (both long taken on main). 109 was claimed by pushing the migration alone in the first commit. A test applies migrations 1 to 108 with the real routine, adds an indexer and a client, lets the real runner apply 109, and checks the rows read back unpinned and the foreign key is enforced.
  • Selection is QueueHandler.clientForGrab (API) and Scheduler.checkPin (auto grab), both calling DownloadClientRepo.ResolvePinnedClient. Unpinned indexers, or no indexer, still go through selectClient and PickClientForMediaType untouched. grab() selects the client after the signing and host match step.
  • One decision picks the indexer. resolveGrabPin (internal/api/grab_pin.go) is a pure function of the caller's id, the release URL and the indexer list, and it runs before signing. Rules: (a) a supplied id that exists and is pinned always wins; (b) a supplied unpinned id is trusted when its host matches the URL, and refused when it contradicts the URL's host while a pinned indexer matches that host; (c) an id that matches no indexer is treated as no id; (d) with no usable id, the indexers on the URL's host decide: none gives no information (unpinned), exactly one is the release's indexer (this finds a recreated indexer and keyless indexers), several are refused when any is pinned. Signing only fills in what the decision left open and never overrides it; the row's indexer_id is the decided indexer. The retry and the pending grab use the same path. A table test covers the whole matrix for keyed and keyless pinned indexers.
  • Host match. For pinning only (signing is unchanged) hosts are compared by name: case, one trailing dot and the port are ignored in every combination, so prowlarr against prowlarr:9696 matches. Ignoring a differing port is deliberate: a spurious refusal is safer than a leak.
  • Failures: *db.PinnedClientError (missing, disabled, wrong protocol). API answers 400 with code indexer_client_unavailable, deliberately not no_download_client. An enabled but unreachable pinned client keeps the usual 502 with text added saying the indexer is pinned and no other client was tried.
  • Scheduler: a pinned release whose client cannot take it is skipped along with that indexer's other releases for the sweep, and the next approved release from another indexer is grabbed. A held (pending) release refused by its pin stays pending, is not deleted, and the next approved pending row is tried. The failure is written to History and sent to the notifier once per book and indexer per 24 hours, naming the indexer only. A pinned indexer's releases never go to another client.
  • Save validation (IndexerHandler): the client must exist (400). A wrong protocol is refused only when the request sets the pin or changes the indexer type, and a disabled client only when the pin is new or changed, so enabling, disabling or reprioritising an indexer still works while its client is down. Grab time fails loudly.
  • Delete: DownloadClientRepo.Delete refuses while any indexer is pinned, the handler answers 409 with an indexers array. The foreign key is the backstop. Disabling a client is allowed.
  • Prowlarr sync, Readarr import, restore: the sync reads the full row and writes it back, so the pin survives (test with the real repo, mutation checked). Readarr import only creates indexers. No restore path writes indexers outside the migration.
  • Frontend: dropdown on the add and edit forms (enabled clients of the indexer protocol, plus the currently pinned client marked disabled or wrong protocol so the form never hides the saved value), type change drops a pin of the other protocol, amber row warning when the pinned client is disabled, missing or the wrong protocol. No warning is shown if the client list failed to load.

Scope decisions

  • The pin wins over priority and media type or category routing, as asked. Documented in the user guide.
  • Indexer and client endpoints stay admin only (RequireAdmin, existing route tests cover them); the new field is only in those responses. Pin failure messages name the client only for admins. History and notifications name the indexer only.
  • No global setting, no change for unpinned indexers.
  • Feature/download client media eligibility #2633 (client media eligibility) was read for structure and not touched or rebased.

Known limits

  • A grab with no indexer id whose download URL is on a host that matches no indexer is routed as if unpinned. That includes aliases of one machine (localhost, 127.0.0.1 and a DNS name), www. and other subdomains, scheme-less indexer URLs and punycode spellings. Supplying indexerId fixes it; the web app and the scheduler always send it.
  • A supplied unpinned indexer id whose host matches the URL is taken at face value, even if a pinned indexer shares the host.
  • On a shared Prowlarr or Jackett host, one pinned indexer makes id-less API grabs fail for every other indexer on that host. The UI and the scheduler send ids and are unaffected.
  • An update body with an explicit empty type keeps the stored type.
  • A down pinned client skips only that indexer's releases. Other indexers keep grabbing.
  • A pre send pin failure creates no Queue row, so there is nothing to retry; grab or search again once fixed.
  • A client whose type is edited to the other protocol after being pinned fails grabs from that indexer until fixed (checked at grab time).
  • Non-English locales fall back to English for the new strings (en.json only, per the repo's partial locale rule).

Checklist

  • Tests added or updated
  • Doc-update gate cleared (docs/User-Guide-Wiki.md, docs/API.md, docs/Troubleshooting-Wiki.md, changelog.d/3080-per-indexer-download-client.md)
  • Wiki pages updated if user-facing behaviour changed (docs/ only, wiki not touched)

Test plan

  • golangci-lint (concurrency 2) on db, api, scheduler, prowlarr, models, indexer and cmd: 0 issues
  • go build ./...
  • go test -count=1 -p 2 ./internal/api/... ./internal/db/... ./internal/scheduler/... ./internal/indexer/... ./internal/migrate/... ./internal/prowlarr/... ./cmd/...
  • web typecheck (tsc, no emit)
  • web full vitest run (161 files, 2041 tests)
  • make smoke not run

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

vavallee and others added 3 commits October 14, 2026 21:08
Storage only: migration 109 and the column plumbing in the indexer repo.
Routing and validation follow in later commits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
…3080)

An indexer can be pinned to one download client. Every grab from it (scheduled,
interactive, pending, retry) goes to that client, ahead of priority and media
type routing. A disabled, missing or wrong protocol client fails the grab with
a message naming the indexer and client, and never falls back. Deleting a
client an indexer is pinned to is refused with 409 listing the indexers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
#3080)

Download client dropdown on the indexer forms listing only enabled clients of
the indexer's protocol, a row warning while the pinned client is disabled,
and the user guide, API, troubleshooting and changelog entries.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

vavallee and others added 5 commits October 14, 2026 22:00
…nt pins (#3080)

A grab or retry with no indexer id is now tied to a pinned indexer by the
release URL's host, and refused when several indexers share the host and one is
pinned. A scheduled search skips a pinned indexer whose client is down and still
grabs from the others, records the failure once a day per book and indexer, and
notifies with the indexer name only. Saving no longer trips over a pinned client
that changed protocol unless the pin or type is being changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
…ts fail to load (#3080)

Also corrects the API, troubleshooting and user guide text about bulk retry,
retry rows and who sees the client name, and lists the known limits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
…in (#3080)

A pending release refused by its pinned client stays pending and the next
approved row is tried. The host check now runs whenever the caller named no
indexer, refuses a contradicting supplied id when a pinned indexer owns the
host, and compares host names ignoring case, a trailing dot and the port. An
empty type in an update is no longer read as a change of type.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
Replaces the interacting heuristics (signing's pick, the host check and the
caller id branch) with one pure function, resolveGrabPin, and a table test of
the whole matrix. Signing can no longer override the decision, an unknown id is
treated as no id, and the row records the decided indexer. An explicit empty
type in an indexer update keeps the stored type.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
@vavallee
vavallee merged commit 962fd59 into main Oct 15, 2026
41 checks passed
francisrath added a commit to francisrath/bindery that referenced this pull request Oct 15, 2026
Resolves IndexersTab.tsx against the pinned download client feature
(vavallee#3283): keeps its clients prop and protocol-change handler, and the
label/id pairs from this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Francis Rath <fr@ncis.no>
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.

Per-indexer download client: send grabs from private trackers to a specific client

1 participant