From af8c74e39ad8466731be7f4b98534771f5397991 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:50:04 +0100 Subject: [PATCH] fix(indexer): harden capability discovery --- crates/arr-indexer/src/lib.rs | 148 ++++++++++++++---- .../tests/fixtures/broken-caps.xml | 2 + .../arr-indexer/tests/fixtures/indexers.json | 4 +- .../tests/fixtures/text-only-caps.xml | 4 +- 4 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 crates/arr-indexer/tests/fixtures/broken-caps.xml diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index fcde9ca..d97b455 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -1,7 +1,8 @@ //! arr-indexer — see DESIGN.md. -use std::collections::BTreeSet; +use std::{collections::BTreeSet, time::Duration}; +use quick_xml::events::Event; use reqwest::Client; use serde::Deserialize; use thiserror::Error; @@ -13,6 +14,7 @@ pub struct Indexer { pub id: i64, pub name: String, pub capabilities: Capabilities, + pub capabilities_error: Option, } /// Search operations supported by one Torznab indexer. @@ -34,9 +36,13 @@ impl SearchMode { /// Returns whether this operation supports an ID-based lookup. #[must_use] pub fn supports_id_search(&self) -> bool { - self.supported_parameters - .iter() - .any(|parameter| parameter.ends_with("id")) + 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. @@ -56,13 +62,21 @@ pub struct ProwlarrClient { impl ProwlarrClient { /// Creates a client using Prowlarr's API key for its REST and Torznab APIs. - #[must_use] - pub fn new(base_url: impl Into, api_key: impl Into) -> Self { - Self { + /// + /// # Errors + /// + /// Returns an error when the HTTP client cannot be created. + pub fn new(base_url: impl Into, api_key: impl Into) -> Result { + 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: Client::new(), - } + client, + }) } /// Enumerates Prowlarr indexers and loads Torznab capabilities for each. @@ -80,49 +94,75 @@ impl ProwlarrClient { .get(format!("{}/api/v1/indexer", self.base_url)) .header("X-Api-Key", &self.api_key) .send() - .await? - .error_for_status()?; - let indexers: Vec = response.json().await?; + .await + .map_err(|_| Error::Request)? + .error_for_status() + .map_err(|_| Error::Request)?; + let indexers: Vec = response.json().await.map_err(|_| Error::Response)?; let mut result = Vec::with_capacity(indexers.len()); - for indexer in indexers { - let capabilities = self.capabilities(indexer.id).await?; + 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 { + async fn capabilities(&self, indexer_id: i64) -> Result { let response = self .client .get(format!("{}/{indexer_id}/api", self.base_url)) .query(&[("apikey", self.api_key.as_str()), ("t", "caps")]) .send() - .await? - .error_for_status()?; - let body = response.text().await?; - Ok(parse_capabilities(&body)?) + .await + .map_err(|_| CapabilityError::Request)? + .error_for_status() + .map_err(|_| CapabilityError::Request)?; + let body = response + .text() + .await + .map_err(|_| CapabilityError::Request)?; + parse_capabilities(&body) } } /// Errors returned while discovering Prowlarr indexers. #[derive(Debug, Error)] pub enum Error { - #[error("Prowlarr request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("invalid Torznab capabilities response: {0}")] - Capabilities(#[from] quick_xml::DeError), + #[error("could not create Prowlarr HTTP client")] + Client, + #[error("Prowlarr indexer request failed")] + Request, + #[error("could not parse Prowlarr indexer response")] + Response, +} + +/// A failed capabilities request for an otherwise usable Prowlarr indexer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CapabilityError { + Request, + InvalidResponse, + Torznab { + code: Option, + description: String, + }, } #[derive(Deserialize)] struct ProwlarrIndexer { id: i64, name: String, + #[serde(rename = "enable")] + enabled: bool, } #[derive(Deserialize)] @@ -131,6 +171,14 @@ struct TorznabCapabilities { searching: Option, } +#[derive(Deserialize)] +struct TorznabError { + #[serde(default, rename = "@code")] + code: Option, + #[serde(default, rename = "@description")] + description: String, +} + #[derive(Deserialize)] struct TorznabSearching { #[serde(default)] @@ -149,8 +197,18 @@ struct TorznabSearchMode { supported_parameters: Option, } -fn parse_capabilities(body: &str) -> Result { - let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?; +fn parse_capabilities(body: &str) -> Result { + 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 { @@ -169,6 +227,23 @@ fn parse_capabilities(body: &str) -> Result { }) } +fn response_root(body: &str) -> Result { + 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 @@ -197,7 +272,7 @@ fn is_available(value: &str) -> bool { #[cfg(test)] mod tests { - use super::ProwlarrClient; + use super::{Capabilities, CapabilityError, ProwlarrClient}; use wiremock::{ matchers::{header, method, path, query_param}, Mock, MockServer, ResponseTemplate, @@ -225,13 +300,20 @@ mod tests { include_str!("../tests/fixtures/text-only-caps.xml"), ) .await; + mount_capabilities( + &server, + 23, + include_str!("../tests/fixtures/broken-caps.xml"), + ) + .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(), 2); + assert_eq!(indexers.len(), 3); assert_eq!(indexers[0].id, 3); assert_eq!(indexers[0].name, "Alpha Tracker"); assert!(indexers[0].capabilities.movie.available); @@ -241,8 +323,20 @@ mod tests { 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(), + }) + ); } async fn mount_capabilities(server: &MockServer, id: i64, body: &str) { diff --git a/crates/arr-indexer/tests/fixtures/broken-caps.xml b/crates/arr-indexer/tests/fixtures/broken-caps.xml new file mode 100644 index 0000000..195f0bf --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/broken-caps.xml @@ -0,0 +1,2 @@ + + diff --git a/crates/arr-indexer/tests/fixtures/indexers.json b/crates/arr-indexer/tests/fixtures/indexers.json index 6e46628..106a129 100644 --- a/crates/arr-indexer/tests/fixtures/indexers.json +++ b/crates/arr-indexer/tests/fixtures/indexers.json @@ -1,4 +1,6 @@ [ { "id": 3, "name": "Alpha Tracker", "enable": true }, - { "id": 17, "name": "Text Only", "enable": true } + { "id": 17, "name": "Text Only", "enable": true }, + { "id": 23, "name": "Broken Tracker", "enable": true }, + { "id": 42, "name": "Disabled Tracker", "enable": false } ] diff --git a/crates/arr-indexer/tests/fixtures/text-only-caps.xml b/crates/arr-indexer/tests/fixtures/text-only-caps.xml index bd6480e..7fcc0b7 100644 --- a/crates/arr-indexer/tests/fixtures/text-only-caps.xml +++ b/crates/arr-indexer/tests/fixtures/text-only-caps.xml @@ -2,8 +2,8 @@ - - + +