feat(indexer): enumerate Prowlarr caps (#51)
ci / rust (push) Successful in 1m3s
ci / web (push) Successful in 8s
e2e / e2e (push) Successful in 48s

This commit was merged in pull request #51.
This commit is contained in:
2026-08-22 20:18:37 +01:00
parent 45cd30ae69
commit e02a9aa813
7 changed files with 436 additions and 0 deletions
+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.
#[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<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
/// 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 {
status: error.status(),
})?
.error_for_status()
.map_err(|error| Error::Request {
status: error.status(),
})?;
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> {
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<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>