fix(indexer): harden capability discovery
This commit is contained in:
+121
-27
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<error code="100" description="Indexer unavailable" />
|
||||||
+3
-1
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user