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
7 changed files with 436 additions and 0 deletions
Generated
+18
View File
@@ -68,6 +68,14 @@ version = "0.1.0"
[[package]]
name = "arr-indexer"
version = "0.1.0"
dependencies = [
"quick-xml",
"reqwest",
"serde",
"thiserror",
"tokio",
"wiremock",
]
[[package]]
name = "arr-meta"
@@ -1204,6 +1212,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quinn"
version = "0.11.11"
+8
View File
@@ -7,6 +7,14 @@ repository.workspace = true
publish = false
[dependencies]
quick-xml.workspace = true
reqwest.workspace = true
serde.workspace = true
thiserror.workspace = true
[dev-dependencies]
tokio.workspace = true
wiremock.workspace = true
[lints]
workspace = true
+383
View File
@@ -1 +1,384 @@
//! arr-indexer — see DESIGN.md.
use std::{collections::BTreeSet, time::Duration};
use quick_xml::events::Event;
use reqwest::{Client, StatusCode};
use serde::Deserialize;
use thiserror::Error;
/// A Prowlarr indexer and the Torznab capabilities it advertises.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Indexer {
/// Stable Prowlarr ID, retained on releases for tracker-specific rules.
pub id: i64,
pub name: String,
pub capabilities: Capabilities,
pub capabilities_error: Option<CapabilityError>,
}
/// Search operations supported by one Torznab indexer.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct Capabilities {
pub search: SearchMode,
pub movie: SearchMode,
pub tv: SearchMode,
}
/// One Torznab search operation and the parameters it accepts.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SearchMode {
pub available: bool,
pub supported_parameters: BTreeSet<String>,
}
impl SearchMode {
/// Returns whether this operation supports an ID-based lookup.
Review

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"} -->
#[must_use]
pub fn supports_id_search(&self) -> bool {
self.available
&& self.supported_parameters.iter().any(|parameter| {
matches!(
parameter.as_str(),
"imdbid" | "tmdbid" | "tvdbid" | "tvmazeid" | "rid" | "traktid"
)
})
}
/// Returns whether the indexer accepts a particular Torznab parameter.
#[must_use]
pub fn supports_parameter(&self, parameter: &str) -> bool {
self.supported_parameters.contains(parameter)
}
}
/// Client for Prowlarr's indexer and per-indexer Torznab endpoints.
#[derive(Clone, Debug)]
pub struct ProwlarrClient {
base_url: String,
api_key: String,
client: Client,
}
impl ProwlarrClient {
/// Creates a client using Prowlarr's API key for its REST and Torznab APIs.
Review

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"} -->
///
/// # Errors
///
/// Returns an error when the HTTP client cannot be created.
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self, Error> {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|_| Error::Client)?;
Ok(Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
api_key: api_key.into(),
client,
})
}
/// Enumerates Prowlarr indexers and loads Torznab capabilities for each.
///
/// Indexers are requested individually so later search and seeding logic
/// retains the tracker identity instead of using Prowlarr's aggregate API.
///
/// # Errors
///
/// Returns an error when Prowlarr rejects a request or a caps response is
Review

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"} -->
/// not valid Torznab XML.
pub async fn indexers(&self) -> Result<Vec<Indexer>, Error> {
let response = self
.client
.get(format!("{}/api/v1/indexer", self.base_url))
.header("X-Api-Key", &self.api_key)
.send()
.await
.map_err(|error| Error::Request {
Outdated
Review

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"} -->
status: error.status(),
})?
.error_for_status()
.map_err(|error| Error::Request {
status: error.status(),
})?;
Outdated
Review

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"} -->
let indexers: Vec<ProwlarrIndexer> = response.json().await.map_err(|_| Error::Response)?;
let mut result = Vec::with_capacity(indexers.len());
for indexer in indexers.into_iter().filter(|indexer| indexer.enabled) {
let (capabilities, capabilities_error) = match self.capabilities(indexer.id).await {
Ok(capabilities) => (capabilities, None),
Err(error) => (Capabilities::default(), Some(error)),
};
result.push(Indexer {
id: indexer.id,
name: indexer.name,
capabilities,
capabilities_error,
});
}
Ok(result)
}
async fn capabilities(&self, indexer_id: i64) -> Result<Capabilities, CapabilityError> {
Outdated
Review

/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"} -->
let response = self
.client
.get(format!("{}/{indexer_id}/api", self.base_url))
.query(&[("apikey", self.api_key.as_str()), ("t", "caps")])
.send()
.await
.map_err(|error| CapabilityError::Request {
status: error.status(),
})?
.error_for_status()
.map_err(|error| CapabilityError::Request {
status: error.status(),
})?;
let body = response
.text()
.await
.map_err(|error| CapabilityError::Request {
status: error.status(),
})?;
parse_capabilities(&body)
}
}
/// Errors returned while discovering Prowlarr indexers.
#[derive(Debug, Error)]
pub enum Error {
#[error("could not create Prowlarr HTTP client")]
Review

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"} -->
Client,
#[error("Prowlarr indexer request failed (status: {status:?})")]
Outdated
Review

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"} -->
Request { status: Option<StatusCode> },
#[error("could not parse Prowlarr indexer response")]
Response,
}
/// A failed capabilities request for an otherwise usable Prowlarr indexer.
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum CapabilityError {
#[error("capabilities request failed (status: {status:?})")]
Request { status: Option<StatusCode> },
#[error("capabilities response was not valid Torznab XML")]
InvalidResponse,
#[error("tracker returned Torznab error {code:?}: {description}")]
Torznab {
code: Option<u16>,
description: String,
},
}
#[derive(Deserialize)]
struct ProwlarrIndexer {
id: i64,
name: String,
#[serde(rename = "enable")]
enabled: bool,
}
#[derive(Deserialize)]
struct TorznabCapabilities {
#[serde(default)]
searching: Option<TorznabSearching>,
}
#[derive(Deserialize)]
struct TorznabError {
#[serde(default, rename = "@code")]
code: Option<u16>,
#[serde(default, rename = "@description")]
description: String,
}
#[derive(Deserialize)]
struct TorznabSearching {
#[serde(default)]
search: Option<TorznabSearchMode>,
#[serde(default, rename = "tv-search")]
tv: Option<TorznabSearchMode>,
#[serde(default, rename = "movie-search")]
movie: Option<TorznabSearchMode>,
}
#[derive(Deserialize)]
struct TorznabSearchMode {
#[serde(default, rename = "@available")]
available: Option<String>,
#[serde(default, rename = "@supportedParams")]
supported_parameters: Option<String>,
}
fn parse_capabilities(body: &str) -> Result<Capabilities, CapabilityError> {
if response_root(body)? == "error" {
let error: TorznabError =
quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?;
return Err(CapabilityError::Torznab {
code: error.code,
description: error.description,
});
}
let capabilities: TorznabCapabilities =
quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?;
let searching = capabilities.searching;
Ok(Capabilities {
search: searching
.as_ref()
.and_then(|modes| modes.search.as_ref())
.map_or_else(SearchMode::default, SearchMode::from),
movie: searching
.as_ref()
.and_then(|modes| modes.movie.as_ref())
.map_or_else(SearchMode::default, SearchMode::from),
tv: searching
.as_ref()
.and_then(|modes| modes.tv.as_ref())
.map_or_else(SearchMode::default, SearchMode::from),
})
}
fn response_root(body: &str) -> Result<String, CapabilityError> {
let mut reader = quick_xml::Reader::from_str(body);
loop {
match reader
.read_event()
.map_err(|_| CapabilityError::InvalidResponse)?
{
Event::Start(element) | Event::Empty(element) => {
return String::from_utf8(element.name().as_ref().to_owned())
.map_err(|_| CapabilityError::InvalidResponse);
}
Event::Eof => return Err(CapabilityError::InvalidResponse),
_ => {}
}
}
}
impl From<&TorznabSearchMode> for SearchMode {
fn from(mode: &TorznabSearchMode) -> Self {
let supported_parameters = mode
.supported_parameters
.as_deref()
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|parameter| !parameter.is_empty())
.map(str::to_owned)
.collect();
Self {
available: mode.available.as_deref().is_some_and(is_available),
supported_parameters,
}
}
}
fn is_available(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"yes" | "true" | "1"
)
}
#[cfg(test)]
mod tests {
use super::{Capabilities, CapabilityError, ProwlarrClient};
use reqwest::StatusCode;
use wiremock::{
matchers::{header, method, path, query_param},
Mock, MockServer, ResponseTemplate,
};
const API_KEY: &str = "test-api-key";
#[tokio::test]
async fn enumerates_indexers_with_recorded_capabilities() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.and(header("X-Api-Key", API_KEY))
.respond_with(ResponseTemplate::new(200).set_body_raw(
include_str!("../tests/fixtures/indexers.json"),
"application/json",
))
.mount(&server)
.await;
mount_capabilities(&server, 3, include_str!("../tests/fixtures/alpha-caps.xml")).await;
mount_capabilities(
&server,
17,
include_str!("../tests/fixtures/text-only-caps.xml"),
)
.await;
mount_capabilities(
&server,
23,
include_str!("../tests/fixtures/broken-caps.xml"),
)
.await;
mount_failure(&server, 29).await;
let indexers = ProwlarrClient::new(server.uri(), API_KEY)
.expect("HTTP client can be created")
.indexers()
.await
.expect("recorded Prowlarr responses are valid");
assert_eq!(indexers.len(), 4);
assert_eq!(indexers[0].id, 3);
assert_eq!(indexers[0].name, "Alpha Tracker");
assert!(indexers[0].capabilities.movie.available);
assert!(indexers[0].capabilities.movie.supports_id_search());
assert!(indexers[0].capabilities.tv.supports_parameter("season"));
assert_eq!(indexers[1].id, 17);
assert_eq!(indexers[1].name, "Text Only");
assert!(indexers[1].capabilities.search.available);
assert!(!indexers[1].capabilities.search.supports_id_search());
assert!(!indexers[1].capabilities.movie.supports_id_search());
assert!(!indexers[1].capabilities.tv.available);
assert!(!indexers[1].capabilities.tv.supports_id_search());
assert_eq!(indexers[2].id, 23);
assert_eq!(indexers[2].capabilities, Capabilities::default());
assert_eq!(
indexers[2].capabilities_error,
Some(CapabilityError::Torznab {
code: Some(100),
description: "Indexer unavailable".to_owned(),
})
);
assert_eq!(indexers[3].id, 29);
assert_eq!(
indexers[3].capabilities_error,
Some(CapabilityError::Request {
status: Some(StatusCode::SERVICE_UNAVAILABLE),
})
);
}
async fn mount_capabilities(server: &MockServer, id: i64, body: &str) {
Mock::given(method("GET"))
.and(path(format!("/{id}/api")))
.and(query_param("apikey", API_KEY))
.and(query_param("t", "caps"))
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/xml"))
.mount(server)
.await;
}
async fn mount_failure(server: &MockServer, id: i64) {
Mock::given(method("GET"))
.and(path(format!("/{id}/api")))
.and(query_param("apikey", API_KEY))
.and(query_param("t", "caps"))
.respond_with(ResponseTemplate::new(503))
.mount(server)
.await;
}
}
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.0" title="Alpha Tracker" />
<searching>
<search available="yes" supportedParams="q" />
<tv-search available="yes" supportedParams="q,season,ep,tvdbid,imdbid" />
<movie-search available="yes" supportedParams="q,imdbid,tmdbid" />
</searching>
</caps>
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<error code="100" description="Indexer unavailable" />
+7
View File
@@ -0,0 +1,7 @@
[
{ "id": 3, "name": "Alpha Tracker", "enable": true },
{ "id": 17, "name": "Text Only", "enable": true },
{ "id": 23, "name": "Broken Tracker", "enable": true },
{ "id": 29, "name": "Unavailable Tracker", "enable": true },
{ "id": 42, "name": "Disabled Tracker", "enable": false }
]
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<caps>
<server version="1.0" title="Text Only" />
<searching>
<search available="true" supportedParams="q,categoryid" />
<tv-search available="no" supportedParams="q,imdbid" />
<movie-search available="1" supportedParams="q" />
</searching>
</caps>