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

This commit is contained in:
Miguel Palhas
2026-08-22 19:50:04 +01:00
parent 9a436fabc1
commit af8c74e39a
4 changed files with 128 additions and 30 deletions
+121 -27
View File
@@ -1,7 +1,8 @@
//! arr-indexer — see DESIGN.md. //! arr-indexer — see DESIGN.md.
use std::collections::BTreeSet; use std::{collections::BTreeSet, time::Duration};
use quick_xml::events::Event;
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
@@ -13,6 +14,7 @@ pub struct Indexer {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub capabilities: Capabilities, pub capabilities: Capabilities,
pub capabilities_error: Option<CapabilityError>,
} }
/// Search operations supported by one Torznab indexer. /// Search operations supported by one Torznab indexer.
@@ -34,9 +36,13 @@ impl SearchMode {
/// Returns whether this operation supports an ID-based lookup. /// Returns whether this operation supports an ID-based lookup.
#[must_use] #[must_use]
pub fn supports_id_search(&self) -> bool { pub fn supports_id_search(&self) -> bool {
self.supported_parameters self.available
.iter() && self.supported_parameters.iter().any(|parameter| {
.any(|parameter| parameter.ends_with("id")) matches!(
parameter.as_str(),
"imdbid" | "tmdbid" | "tvdbid" | "tvmazeid" | "rid" | "traktid"
)
})
} }
/// Returns whether the indexer accepts a particular Torznab parameter. /// Returns whether the indexer accepts a particular Torznab parameter.
@@ -56,13 +62,21 @@ pub struct ProwlarrClient {
impl ProwlarrClient { impl ProwlarrClient {
/// Creates a client using Prowlarr's API key for its REST and Torznab APIs. /// Creates a client using Prowlarr's API key for its REST and Torznab APIs.
#[must_use] ///
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self { /// # Errors
Self { ///
/// 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(), base_url: base_url.into().trim_end_matches('/').to_owned(),
api_key: api_key.into(), api_key: api_key.into(),
client: Client::new(), client,
} })
} }
/// Enumerates Prowlarr indexers and loads Torznab capabilities for each. /// Enumerates Prowlarr indexers and loads Torznab capabilities for each.
@@ -80,49 +94,75 @@ impl ProwlarrClient {
.get(format!("{}/api/v1/indexer", self.base_url)) .get(format!("{}/api/v1/indexer", self.base_url))
.header("X-Api-Key", &self.api_key) .header("X-Api-Key", &self.api_key)
.send() .send()
.await? .await
.error_for_status()?; .map_err(|_| Error::Request)?
let indexers: Vec<ProwlarrIndexer> = response.json().await?; .error_for_status()
.map_err(|_| Error::Request)?;
let indexers: Vec<ProwlarrIndexer> = response.json().await.map_err(|_| Error::Response)?;
let mut result = Vec::with_capacity(indexers.len()); let mut result = Vec::with_capacity(indexers.len());
for indexer in indexers { for indexer in indexers.into_iter().filter(|indexer| indexer.enabled) {
let capabilities = self.capabilities(indexer.id).await?; let (capabilities, capabilities_error) = match self.capabilities(indexer.id).await {
Ok(capabilities) => (capabilities, None),
Err(error) => (Capabilities::default(), Some(error)),
};
result.push(Indexer { result.push(Indexer {
id: indexer.id, id: indexer.id,
name: indexer.name, name: indexer.name,
capabilities, capabilities,
capabilities_error,
}); });
} }
Ok(result) Ok(result)
} }
async fn capabilities(&self, indexer_id: i64) -> Result<Capabilities, Error> { async fn capabilities(&self, indexer_id: i64) -> Result<Capabilities, CapabilityError> {
let response = self let response = self
.client .client
.get(format!("{}/{indexer_id}/api", self.base_url)) .get(format!("{}/{indexer_id}/api", self.base_url))
.query(&[("apikey", self.api_key.as_str()), ("t", "caps")]) .query(&[("apikey", self.api_key.as_str()), ("t", "caps")])
.send() .send()
.await? .await
.error_for_status()?; .map_err(|_| CapabilityError::Request)?
let body = response.text().await?; .error_for_status()
Ok(parse_capabilities(&body)?) .map_err(|_| CapabilityError::Request)?;
let body = response
.text()
.await
.map_err(|_| CapabilityError::Request)?;
parse_capabilities(&body)
} }
} }
/// Errors returned while discovering Prowlarr indexers. /// Errors returned while discovering Prowlarr indexers.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
#[error("Prowlarr request failed: {0}")] #[error("could not create Prowlarr HTTP client")]
Request(#[from] reqwest::Error), Client,
#[error("invalid Torznab capabilities response: {0}")] #[error("Prowlarr indexer request failed")]
Capabilities(#[from] quick_xml::DeError), 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<u16>,
description: String,
},
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct ProwlarrIndexer { struct ProwlarrIndexer {
id: i64, id: i64,
name: String, name: String,
#[serde(rename = "enable")]
enabled: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -131,6 +171,14 @@ struct TorznabCapabilities {
searching: Option<TorznabSearching>, searching: Option<TorznabSearching>,
} }
#[derive(Deserialize)]
struct TorznabError {
#[serde(default, rename = "@code")]
code: Option<u16>,
#[serde(default, rename = "@description")]
description: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TorznabSearching { struct TorznabSearching {
#[serde(default)] #[serde(default)]
@@ -149,8 +197,18 @@ struct TorznabSearchMode {
supported_parameters: Option<String>, supported_parameters: Option<String>,
} }
fn parse_capabilities(body: &str) -> Result<Capabilities, quick_xml::DeError> { fn parse_capabilities(body: &str) -> Result<Capabilities, CapabilityError> {
let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?; 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; let searching = capabilities.searching;
Ok(Capabilities { Ok(Capabilities {
@@ -169,6 +227,23 @@ fn parse_capabilities(body: &str) -> Result<Capabilities, quick_xml::DeError> {
}) })
} }
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 { impl From<&TorznabSearchMode> for SearchMode {
fn from(mode: &TorznabSearchMode) -> Self { fn from(mode: &TorznabSearchMode) -> Self {
let supported_parameters = mode let supported_parameters = mode
@@ -197,7 +272,7 @@ fn is_available(value: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ProwlarrClient; use super::{Capabilities, CapabilityError, ProwlarrClient};
use wiremock::{ use wiremock::{
matchers::{header, method, path, query_param}, matchers::{header, method, path, query_param},
Mock, MockServer, ResponseTemplate, Mock, MockServer, ResponseTemplate,
@@ -225,13 +300,20 @@ mod tests {
include_str!("../tests/fixtures/text-only-caps.xml"), include_str!("../tests/fixtures/text-only-caps.xml"),
) )
.await; .await;
mount_capabilities(
&server,
23,
include_str!("../tests/fixtures/broken-caps.xml"),
)
.await;
let indexers = ProwlarrClient::new(server.uri(), API_KEY) let indexers = ProwlarrClient::new(server.uri(), API_KEY)
.expect("HTTP client can be created")
.indexers() .indexers()
.await .await
.expect("recorded Prowlarr responses are valid"); .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].id, 3);
assert_eq!(indexers[0].name, "Alpha Tracker"); assert_eq!(indexers[0].name, "Alpha Tracker");
assert!(indexers[0].capabilities.movie.available); assert!(indexers[0].capabilities.movie.available);
@@ -241,8 +323,20 @@ mod tests {
assert_eq!(indexers[1].id, 17); assert_eq!(indexers[1].id, 17);
assert_eq!(indexers[1].name, "Text Only"); assert_eq!(indexers[1].name, "Text Only");
assert!(indexers[1].capabilities.search.available); 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.movie.supports_id_search());
assert!(!indexers[1].capabilities.tv.available); 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) { async fn mount_capabilities(server: &MockServer, id: i64, body: &str) {
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<error code="100" description="Indexer unavailable" />
+3 -1
View File
@@ -1,4 +1,6 @@
[ [
{ "id": 3, "name": "Alpha Tracker", "enable": true }, { "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 }
] ]
+2 -2
View File
@@ -2,8 +2,8 @@
<caps> <caps>
<server version="1.0" title="Text Only" /> <server version="1.0" title="Text Only" />
<searching> <searching>
<search available="true" supportedParams="q" /> <search available="true" supportedParams="q,categoryid" />
<tv-search available="no" supportedParams="q" /> <tv-search available="no" supportedParams="q,imdbid" />
<movie-search available="1" supportedParams="q" /> <movie-search available="1" supportedParams="q" />
</searching> </searching>
</caps> </caps>