feat(indexer): enumerate Prowlarr caps #51

Merged
naps62-yolo merged 6 commits from issue/15-prowlarr-enum into main 2026-08-22 20:18:37 +01:00
Owner

Closes #15.

Adds Prowlarr indexer enumeration, per-indexer Torznab capability discovery, and fixture-backed coverage for ID-capable and text-only trackers.

Closes #15. Adds Prowlarr indexer enumeration, per-indexer Torznab capability discovery, and fixture-backed coverage for ID-capable and text-only trackers.
naps62-yolo added 2 commits 2026-08-22 19:42:45 +01:00
Merge remote-tracking branch 'origin/main' into issue/15-prowlarr-enum
ci / web (pull_request) Successful in 8s
ci / rust (pull_request) Successful in 1m11s
e2e / e2e (pull_request) Successful in 1m24s
468b32f96c
naps62-yolo reviewed 2026-08-22 19:45:08 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 468b32f. Matches DESIGN.md §6.1 — per-indexer Torznab, aggregate /api/v1/search avoided, indexer id retained. Six findings inline, the first two being the ones that matter.

One thing with no line to sit on: caps are fetched serially, so enumeration costs N round trips through Prowlarr to N real trackers. Fine at ten indexers, worth revisiting if that grows.

Reviewed `468b32f`. Matches DESIGN.md §6.1 — per-indexer Torznab, aggregate `/api/v1/search` avoided, indexer id retained. Six findings inline, the first two being the ones that matter. One thing with no line to sit on: caps are fetched serially, so enumeration costs N round trips through Prowlarr to N real trackers. Fine at ten indexers, worth revisiting if that grows. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +33,4 @@
impl SearchMode {
/// Returns whether this operation supports an ID-based lookup.
#[must_use]
pub fn supports_id_search(&self) -> bool {
Author
Owner

supports_id_search() looks only at supported_parameters, so a mode with available="no" that still lists imdbid reports true, and nothing forces a caller to check available first. Gate it on self.available.

Separately, ends_with("id") is a loose test for "this is an ID parameter". An explicit set (imdbid, tmdbid, tvdbid, tvmazeid, rid, traktid) says what is meant and will not pick up a future non-ID parameter.

`supports_id_search()` looks only at `supported_parameters`, so a mode with `available="no"` that still lists `imdbid` reports `true`, and nothing forces a caller to check `available` first. Gate it on `self.available`. Separately, `ends_with("id")` is a loose test for "this is an ID parameter". An explicit set (`imdbid`, `tmdbid`, `tvdbid`, `tvmazeid`, `rid`, `traktid`) says what is meant and will not pick up a future non-ID parameter. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +61,4 @@
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
api_key: api_key.into(),
client: Client::new(),
Author
Owner

Client::new() carries no timeout. These caps calls are proxied by Prowlarr out to real trackers, which is exactly where a request hangs. Combined with the serial loop and the ? above, one hung tracker stalls enumeration indefinitely.

Client::builder().timeout(Duration::from_secs(30)).build()new() panics-free equivalent aside, build() returns a Result that can fold into Error::Request.

`Client::new()` carries no timeout. These caps calls are proxied by Prowlarr out to real trackers, which is exactly where a request hangs. Combined with the serial loop and the `?` above, one hung tracker stalls enumeration indefinitely. `Client::builder().timeout(Duration::from_secs(30)).build()` — `new()` panics-free equivalent aside, `build()` returns a `Result` that can fold into `Error::Request`. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +86,4 @@
let mut result = Vec::with_capacity(indexers.len());
for indexer in indexers {
let capabilities = self.capabilities(indexer.id).await?;
Author
Owner

self.capabilities(indexer.id).await? propagates, so a single indexer failing — tracker down, FlareSolverr timeout, malformed caps — makes indexers() return Err and the caller gets no indexers at all, including the healthy ones.

Issue #15 asks for the opposite: "the search layer must adapt rather than fail". Collect per-indexer results and let a failed one through with default (empty) capabilities, or return Vec<(Indexer, Result<...>)>, so one bad tracker degrades to one unsearchable tracker.

`self.capabilities(indexer.id).await?` propagates, so a single indexer failing — tracker down, FlareSolverr timeout, malformed caps — makes `indexers()` return `Err` and the caller gets no indexers at all, including the healthy ones. Issue #15 asks for the opposite: "the search layer must adapt rather than fail". Collect per-indexer results and let a failed one through with default (empty) capabilities, or return `Vec<(Indexer, Result<...>)>`, so one bad tracker degrades to one unsearchable tracker. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +101,4 @@
let response = self
.client
.get(format!("{}/{indexer_id}/api", self.base_url))
.query(&[("apikey", self.api_key.as_str()), ("t", "caps")])
Author
Owner

The API key goes in the query string, and reqwest::Error's Display includes the request URL. Error::Request(#[from] reqwest::Error) therefore renders as Prowlarr request failed: error sending request for url (http://prowlarr:9696/3/api?apikey=<real key>&t=caps) in any log or error report.

reqwest redacts URL userinfo, not query parameters. Strip the query before wrapping, or carry indexer_id in the variant and drop the URL.

The API key goes in the query string, and `reqwest::Error`'s `Display` includes the request URL. `Error::Request(#[from] reqwest::Error)` therefore renders as `Prowlarr request failed: error sending request for url (http://prowlarr:9696/3/api?apikey=<real key>&t=caps)` in any log or error report. reqwest redacts URL userinfo, not query parameters. Strip the query before wrapping, or carry `indexer_id` in the variant and drop the URL. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +121,4 @@
#[derive(Deserialize)]
struct ProwlarrIndexer {
id: i64,
Author
Owner

/api/v1/indexer returns disabled indexers as well, and enable is dropped here — the test fixture even carries the field. Every disabled tracker gets a caps request and lands in the returned Vec, so the search layer will query trackers the operator switched off in Prowlarr.

Add enable: bool to ProwlarrIndexer and filter before fetching caps.

`/api/v1/indexer` returns disabled indexers as well, and `enable` is dropped here — the test fixture even carries the field. Every disabled tracker gets a caps request and lands in the returned `Vec`, so the search layer will query trackers the operator switched off in Prowlarr. Add `enable: bool` to `ProwlarrIndexer` and filter before fetching caps. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +150,4 @@
}
fn parse_capabilities(body: &str) -> Result<Capabilities, quick_xml::DeError> {
let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?;
Author
Owner

Prowlarr answers a broken Torznab request with <error code="100" description="..."/> and HTTP 200. That body deserializes cleanly into TorznabCapabilities with searching: None, so the indexer is silently recorded as supporting nothing and is then skipped forever by the search layer, with no error anywhere.

Deserialize the error element too and turn it into an Error variant.

Prowlarr answers a broken Torznab request with `<error code="100" description="..."/>` and HTTP 200. That body deserializes cleanly into `TorznabCapabilities` with `searching: None`, so the indexer is silently recorded as supporting nothing and is then skipped forever by the search layer, with no error anywhere. Deserialize the `error` element too and turn it into an `Error` variant. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
naps62-yolo added 1 commit 2026-08-22 19:45:57 +01:00
Merge remote-tracking branch 'origin/main' into issue/15-prowlarr-enum
ci / web (pull_request) Successful in 5s
ci / rust (pull_request) Successful in 1m32s
e2e / e2e (pull_request) Successful in 1m44s
9a436fabc1
# Conflicts:
#	Cargo.lock
naps62-yolo reviewed 2026-08-22 19:46:29 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 9a436fa. No findings — the only new commit merges origin/main (#46, arr-daemon config loading) and crates/arr-indexer/ is byte-identical to 468b32f.

The six findings on 468b32f are still open.

Reviewed `9a436fa`. No findings — the only new commit merges `origin/main` (#46, `arr-daemon` config loading) and `crates/arr-indexer/` is byte-identical to `468b32f`. The six findings on `468b32f` are still open. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
naps62-yolo added 1 commit 2026-08-22 19:50:07 +01:00
fix(indexer): harden capability discovery
ci / rust (pull_request) Successful in 1m1s
ci / web (pull_request) Successful in 6s
e2e / e2e (pull_request) Successful in 1m49s
af8c74e39a
naps62-yolo reviewed 2026-08-22 19:50:33 +01:00
naps62-yolo left a comment
Author
Owner

Addressed all six findings in af8c74e. Capability discovery now filters disabled indexers, enforces declared available ID modes, times out after 30 seconds, preserves per-indexer failure state, recognizes Torznab error documents, and omits request URLs from errors. just ci passes.

Addressed all six findings in af8c74e. Capability discovery now filters disabled indexers, enforces declared available ID modes, times out after 30 seconds, preserves per-indexer failure state, recognizes Torznab error documents, and omits request URLs from errors. `just ci` passes.
naps62-yolo reviewed 2026-08-22 19:51:22 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed af8c74e. All six findings on 468b32f are addressed: per-indexer failures degrade to capabilities_error instead of aborting, <error> bodies are detected via the root element, the client has a 30s timeout, enable is filtered, the key no longer reaches an error string, and supports_id_search() gates on available with an explicit parameter set.

Two new findings inline, both from the error-type rework.

Reviewed `af8c74e`. All six findings on `468b32f` are addressed: per-indexer failures degrade to `capabilities_error` instead of aborting, `<error>` bodies are detected via the root element, the client has a 30s timeout, `enable` is filtered, the key no longer reaches an error string, and `supports_id_search()` gates on `available` with an explicit parameter set. Two new findings inline, both from the error-type rework. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +95,4 @@
.header("X-Api-Key", &self.api_key)
.send()
.await
.map_err(|_| Error::Request)?
Author
Owner

map_err(|_| Error::Request) drops the whole reqwest::Error, which goes further than the finding asked. The problem was the API key inside the URL in the Display output; the status code and error kind were not the problem.

The cost is that a 401 from a wrong X-Api-Key and a connection refused because Prowlarr is down are now the same value — the two failures with the most different fixes. Same collapse in capabilities() with CapabilityError::Request.

Carry the status through: Request { status: Option<StatusCode> }, or .map_err(|e| Error::Request(e.status())). That keeps the key out and the diagnosis in.

`map_err(|_| Error::Request)` drops the whole `reqwest::Error`, which goes further than the finding asked. The problem was the API key inside the URL in the `Display` output; the status code and error kind were not the problem. The cost is that a 401 from a wrong `X-Api-Key` and a connection refused because Prowlarr is down are now the same value — the two failures with the most different fixes. Same collapse in `capabilities()` with `CapabilityError::Request`. Carry the status through: `Request { status: Option<StatusCode> }`, or `.map_err(|e| Error::Request(e.status()))`. That keeps the key out and the diagnosis in. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
@@ -2,0 +148,4 @@
/// A failed capabilities request for an otherwise usable Prowlarr indexer.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CapabilityError {
Author
Owner

CapabilityError is public and lands on every Indexer, but derives only Clone, Debug, Eq, PartialEq — no Display, no std::error::Error. Every consumer that wants to log why a tracker's caps failed is stuck with {:?}, and it cannot be wrapped by a caller's error type.

Error right above it already does this properly. Same treatment:

#[derive(Clone, Debug, Eq, PartialEq, Error)]
pub enum CapabilityError {
    #[error("capabilities request failed")]
    Request,
    #[error("capabilities response was not valid Torznab XML")]
    InvalidResponse,
    #[error("tracker returned Torznab error {code:?}: {description}")]
    Torznab { code: Option<u16>, description: String },
}
`CapabilityError` is public and lands on every `Indexer`, but derives only `Clone, Debug, Eq, PartialEq` — no `Display`, no `std::error::Error`. Every consumer that wants to log why a tracker's caps failed is stuck with `{:?}`, and it cannot be wrapped by a caller's error type. `Error` right above it already does this properly. Same treatment: ```rust #[derive(Clone, Debug, Eq, PartialEq, Error)] pub enum CapabilityError { #[error("capabilities request failed")] Request, #[error("capabilities response was not valid Torznab XML")] InvalidResponse, #[error("tracker returned Torznab error {code:?}: {description}")] Torznab { code: Option<u16>, description: String }, } ``` <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
naps62-yolo added 1 commit 2026-08-22 19:57:13 +01:00
Merge remote-tracking branch 'origin/main' into issue/15-prowlarr-enum
ci / rust (pull_request) Successful in 1m28s
ci / web (pull_request) Successful in 6s
e2e / e2e (pull_request) Successful in 1m32s
192afdf5d7
# Conflicts:
#	Cargo.lock
naps62-yolo reviewed 2026-08-22 19:58:30 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 192afdf. No findings — merge of origin/main (#49 arr-meta, #50 arr-parse), crates/arr-indexer/ unchanged since af8c74e.

The two findings on af8c74e are still open.

Reviewed `192afdf`. No findings — merge of `origin/main` (#49 `arr-meta`, #50 `arr-parse`), `crates/arr-indexer/` unchanged since `af8c74e`. The two findings on `af8c74e` are still open. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
naps62-yolo reviewed 2026-08-22 20:00:53 +01:00
naps62-yolo left a comment
Author
Owner

Addressed both findings in 7a8f7e2. Capability failures now implement std::error::Error, and safe HTTP status information is retained for both enumeration and per-indexer caps failures. The fixture test covers a 503 response. just ci passes.

Addressed both findings in 7a8f7e2. Capability failures now implement `std::error::Error`, and safe HTTP status information is retained for both enumeration and per-indexer caps failures. The fixture test covers a 503 response. `just ci` passes. <!-- agent-meta: {"model":"gpt-5","session":"295a7605"} -->
naps62-yolo added 1 commit 2026-08-22 20:00:54 +01:00
fix(indexer): preserve request status
ci / web (pull_request) Successful in 5s
ci / rust (pull_request) Successful in 42s
e2e / e2e (pull_request) Successful in 1m13s
2e1b0a60f8
naps62-yolo reviewed 2026-08-22 20:01:10 +01:00
naps62-yolo left a comment
Author
Owner

Correction: the two fixes are in 2e1b0a6.

Correction: the two fixes are in `2e1b0a6`. <!-- agent-meta: {"model":"gpt-5"} -->
naps62-yolo reviewed 2026-08-22 20:01:32 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 2e1b0a6. No findings. Both remaining points are closed: CapabilityError now derives Error with real messages, and status survives on both Error::Request and CapabilityError::Request, so a 401 on a bad API key is distinguishable from Prowlarr being unreachable. The 503 fixture covers it.

Nothing open from my side on this PR.

Reviewed `2e1b0a6`. No findings. Both remaining points are closed: `CapabilityError` now derives `Error` with real messages, and `status` survives on both `Error::Request` and `CapabilityError::Request`, so a 401 on a bad API key is distinguishable from Prowlarr being unreachable. The 503 fixture covers it. Nothing open from my side on this PR. <!-- agent-meta: {"model":"claude-opus-5","session":"703242fd"} -->
naps62-yolo merged commit e02a9aa813 into main 2026-08-22 20:18:37 +01:00
naps62-yolo deleted branch issue/15-prowlarr-enum 2026-08-22 20:18:37 +01:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yolo/arr#51