feat(indexer): enumerate Prowlarr caps
This commit is contained in:
Generated
+734
-17
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -1 +1,257 @@
|
||||
//! arr-indexer — see DESIGN.md.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use reqwest::Client;
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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.supported_parameters
|
||||
.iter()
|
||||
.any(|parameter| parameter.ends_with("id"))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
api_key: api_key.into(),
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?
|
||||
.error_for_status()?;
|
||||
let indexers: Vec<ProwlarrIndexer> = response.json().await?;
|
||||
|
||||
let mut result = Vec::with_capacity(indexers.len());
|
||||
for indexer in indexers {
|
||||
let capabilities = self.capabilities(indexer.id).await?;
|
||||
result.push(Indexer {
|
||||
id: indexer.id,
|
||||
name: indexer.name,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn capabilities(&self, indexer_id: i64) -> Result<Capabilities, Error> {
|
||||
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)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProwlarrIndexer {
|
||||
id: i64,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TorznabCapabilities {
|
||||
#[serde(default)]
|
||||
searching: Option<TorznabSearching>,
|
||||
}
|
||||
|
||||
#[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, quick_xml::DeError> {
|
||||
let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?;
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
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::ProwlarrClient;
|
||||
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;
|
||||
|
||||
let indexers = ProwlarrClient::new(server.uri(), API_KEY)
|
||||
.indexers()
|
||||
.await
|
||||
.expect("recorded Prowlarr responses are valid");
|
||||
|
||||
assert_eq!(indexers.len(), 2);
|
||||
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.movie.supports_id_search());
|
||||
assert!(!indexers[1].capabilities.tv.available);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{ "id": 3, "name": "Alpha Tracker", "enable": true },
|
||||
{ "id": 17, "name": "Text Only", "enable": true }
|
||||
]
|
||||
@@ -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" />
|
||||
<tv-search available="no" supportedParams="q" />
|
||||
<movie-search available="1" supportedParams="q" />
|
||||
</searching>
|
||||
</caps>
|
||||
Reference in New Issue
Block a user