diff --git a/Cargo.lock b/Cargo.lock index 95fdfc2..4e13b2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,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" @@ -1315,6 +1323,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" diff --git a/crates/arr-indexer/Cargo.toml b/crates/arr-indexer/Cargo.toml index 083d65f..db37235 100644 --- a/crates/arr-indexer/Cargo.toml +++ b/crates/arr-indexer/Cargo.toml @@ -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 diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index 48ac917..a09133e 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -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, +} + +/// 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, +} + +impl SearchMode { + /// Returns whether this operation supports an ID-based lookup. + #[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. + /// + /// # 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, + }) + } + + /// 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 + /// not valid Torznab XML. + pub async fn indexers(&self) -> Result, 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 { + status: error.status(), + })? + .error_for_status() + .map_err(|error| Error::Request { + status: error.status(), + })?; + let indexers: Vec = 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 { + 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")] + Client, + #[error("Prowlarr indexer request failed (status: {status:?})")] + Request { status: Option }, + #[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 }, + #[error("capabilities response was not valid Torznab XML")] + InvalidResponse, + #[error("tracker returned Torznab error {code:?}: {description}")] + Torznab { + code: Option, + description: String, + }, +} + +#[derive(Deserialize)] +struct ProwlarrIndexer { + id: i64, + name: String, + #[serde(rename = "enable")] + enabled: bool, +} + +#[derive(Deserialize)] +struct TorznabCapabilities { + #[serde(default)] + searching: Option, +} + +#[derive(Deserialize)] +struct TorznabError { + #[serde(default, rename = "@code")] + code: Option, + #[serde(default, rename = "@description")] + description: String, +} + +#[derive(Deserialize)] +struct TorznabSearching { + #[serde(default)] + search: Option, + #[serde(default, rename = "tv-search")] + tv: Option, + #[serde(default, rename = "movie-search")] + movie: Option, +} + +#[derive(Deserialize)] +struct TorznabSearchMode { + #[serde(default, rename = "@available")] + available: Option, + #[serde(default, rename = "@supportedParams")] + supported_parameters: Option, +} + +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 { + 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 { + 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; + } +} diff --git a/crates/arr-indexer/tests/fixtures/alpha-caps.xml b/crates/arr-indexer/tests/fixtures/alpha-caps.xml new file mode 100644 index 0000000..07a9bc7 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/alpha-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + 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 new file mode 100644 index 0000000..68fd643 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/indexers.json @@ -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 } +] diff --git a/crates/arr-indexer/tests/fixtures/text-only-caps.xml b/crates/arr-indexer/tests/fixtures/text-only-caps.xml new file mode 100644 index 0000000..7fcc0b7 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/text-only-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + +