From 45cd30ae6911fd63d3bbec362cb8f49897b935bd Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 20:18:28 +0100 Subject: [PATCH 1/2] Policy filter and verdict pipeline (#54) --- Cargo.lock | 3 + crates/arr-core/Cargo.toml | 1 + crates/arr-core/src/lib.rs | 51 ++-- crates/arr-core/src/policy.rs | 486 ++++++++++++++++++++++++++++++++++ 4 files changed, 525 insertions(+), 16 deletions(-) create mode 100644 crates/arr-core/src/policy.rs diff --git a/Cargo.lock b/Cargo.lock index 8904f19..95fdfc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,9 @@ version = "0.1.0" [[package]] name = "arr-core" version = "0.1.0" +dependencies = [ + "arr-parse", +] [[package]] name = "arr-daemon" diff --git a/crates/arr-core/Cargo.toml b/crates/arr-core/Cargo.toml index d0c87cd..bc13fef 100644 --- a/crates/arr-core/Cargo.toml +++ b/crates/arr-core/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true publish = false [dependencies] +arr-parse.workspace = true [lints] workspace = true diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index be1badf..04ef0a4 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -4,6 +4,10 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; +pub mod policy; + +pub use arr_parse::NameClaims as ParsedRelease; + macro_rules! id_type { ($name:ident) => { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -56,12 +60,43 @@ pub enum Source { WebDl, WebRip, Hdtv, + Dvd, + Telecine, Telesync, Cam, Screener, Other, } +impl From for Resolution { + fn from(value: arr_parse::Resolution) -> Self { + match value { + arr_parse::Resolution::P480 => Self::Other(480), + arr_parse::Resolution::P576 => Self::Other(576), + arr_parse::Resolution::P720 => Self::R720p, + arr_parse::Resolution::P1080 => Self::R1080p, + arr_parse::Resolution::P2160 => Self::R2160p, + } + } +} + +impl From for Source { + fn from(value: arr_parse::Source) -> Self { + match value { + arr_parse::Source::Cam => Self::Cam, + arr_parse::Source::Telesync => Self::Telesync, + arr_parse::Source::Telecine => Self::Telecine, + arr_parse::Source::Screener => Self::Screener, + arr_parse::Source::Dvd => Self::Dvd, + arr_parse::Source::Hdtv => Self::Hdtv, + arr_parse::Source::WebRip => Self::WebRip, + arr_parse::Source::WebDl => Self::WebDl, + arr_parse::Source::BluRay => Self::BluRay, + arr_parse::Source::Remux => Self::Remux, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DolbyVisionProfile { pub profile: u8, @@ -77,14 +112,6 @@ pub enum HdrFormat { DolbyVision(DolbyVisionProfile), } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum HdrClaim { - Hdr10, - Hdr10Plus, - Hlg, - DolbyVision, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub enum RequiredAudio { OriginalLanguage, @@ -192,14 +219,6 @@ pub struct MediaFile { pub waiver: Option, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ParsedRelease { - pub resolution: Option, - pub source: Option, - pub hdr: Vec, - pub audio_languages: Vec, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct Release { pub id: ReleaseId, diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs new file mode 100644 index 0000000..1cb0b6c --- /dev/null +++ b/crates/arr-core/src/policy.rs @@ -0,0 +1,486 @@ +use arr_parse::NameClaims; + +use crate::{MovieOverrides, Policy, ProbedMedia, Resolution, Rule, Source, Verdict}; + +/// The evidence available while evaluating a candidate. +#[derive(Clone, Copy, Debug)] +pub enum Candidate<'a> { + /// Claims parsed from a release name before a grab. + PreGrab(&'a NameClaims), + /// Facts reported by `ffprobe` after a download. + PostDownload(&'a ProbedMedia), +} + +impl Candidate<'_> { + /// Which side of the grab boundary supplied the evidence. + #[must_use] + pub const fn phase(&self) -> EvaluationPhase { + match self { + Self::PreGrab(_) => EvaluationPhase::PreGrab, + Self::PostDownload(_) => EvaluationPhase::PostDownload, + } + } + + fn resolution(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.resolution.map(Resolution::from), + Self::PostDownload(media) => Some(media.resolution), + } + } + + fn source(&self) -> Option { + match self { + Self::PreGrab(claims) => claims.source.map(Source::from), + Self::PostDownload(media) => media.source, + } + } +} + +/// The two phases of policy evaluation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EvaluationPhase { + PreGrab, + PostDownload, +} + +/// Everything a policy rule may inspect. +#[derive(Clone, Copy, Debug)] +pub struct EvaluationContext<'a> { + pub policy: &'a Policy, + pub overrides: &'a MovieOverrides, + pub candidate: Candidate<'a>, +} + +/// A rule's identity when no violation exists to carry concrete evidence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleKind { + RequiredAudio, + DubBlacklist, + DolbyVisionProfile, + Resolution, + Source, + Size, + Other(String), +} + +/// The result of evaluating one rule. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuleEvaluation { + Pass(RuleKind), + Unknown(RuleKind), + SoftFail(Rule), + HardFail(Rule), +} + +/// A policy rule that can participate in the verdict pipeline. +pub trait PolicyRule: std::fmt::Debug { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation; +} + +/// Full evaluation details, including rules whose evidence was unknown. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Evaluation { + pub verdict: Verdict, + pub rules: Vec, +} + +/// Apply the built-in filter rules and retain the verdict and every rule result. +#[must_use] +pub fn evaluate( + policy: &Policy, + overrides: &MovieOverrides, + candidate: Candidate<'_>, +) -> Evaluation { + let context = EvaluationContext { + policy, + overrides, + candidate, + }; + let rules: [&dyn PolicyRule; 2] = [&ResolutionRule, &SourceRule]; + evaluate_rules(&context, &rules) +} + +/// Reduce an ordered set of policy rules to a verdict. +/// +/// The first hard failure decides rejection. If none exists, the first soft +/// failure decides the waiver. Unknown evaluations abstain from reduction and +/// remain visible in the returned report. +#[must_use] +pub fn evaluate_rules(context: &EvaluationContext<'_>, rules: &[&dyn PolicyRule]) -> Evaluation { + let evaluations: Vec = + rules.iter().map(|rule| rule.evaluate(context)).collect(); + + let hard_failure = evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::HardFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }); + let soft_failure = || { + evaluations.iter().find_map(|evaluation| { + if let RuleEvaluation::SoftFail(rule) = evaluation { + Some(rule.clone()) + } else { + None + } + }) + }; + let verdict = hard_failure.map_or_else( + || soft_failure().map_or(Verdict::Eligible, Verdict::Waived), + Verdict::Rejected, + ); + + Evaluation { + verdict, + rules: evaluations, + } +} + +/// Checks whether the candidate's resolution is allowed by the effective policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct ResolutionRule; + +impl PolicyRule for ResolutionRule { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { + let Some(resolution) = context.candidate.resolution() else { + return RuleEvaluation::Unknown(RuleKind::Resolution); + }; + + let allowed_by_root = context.policy.resolution_preference.contains(&resolution); + if allowed_by_root && (!context.overrides.only_4k || resolution == Resolution::R2160p) { + RuleEvaluation::Pass(RuleKind::Resolution) + } else if allowed_by_root + && context.overrides.only_4k + && context.candidate.phase() == EvaluationPhase::PostDownload + { + RuleEvaluation::SoftFail(Rule::Resolution(resolution)) + } else { + RuleEvaluation::HardFail(Rule::Resolution(resolution)) + } + } +} + +/// Rejects sources that are never watchable release candidates. +#[derive(Clone, Copy, Debug, Default)] +pub struct SourceRule; + +impl PolicyRule for SourceRule { + fn evaluate(&self, context: &EvaluationContext<'_>) -> RuleEvaluation { + let Some(source) = context.candidate.source() else { + return RuleEvaluation::Unknown(RuleKind::Source); + }; + match source { + Source::Cam | Source::Telesync | Source::Screener => { + RuleEvaluation::HardFail(Rule::Source(source)) + } + Source::Other => RuleEvaluation::Unknown(RuleKind::Source), + Source::Dvd + | Source::Telecine + | Source::Hdtv + | Source::WebRip + | Source::WebDl + | Source::BluRay + | Source::Remux => RuleEvaluation::Pass(RuleKind::Source), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use arr_parse::{NameClaims, Resolution as ClaimedResolution, Source as ClaimedSource}; + + use super::*; + use crate::{HdrFormat, HdrRules, PolicyId, RequiredAudio}; + + fn policy() -> Policy { + Policy { + id: PolicyId(1), + name: "test".to_owned(), + required_audio: RequiredAudio::OriginalLanguage, + dub_blacklist: Vec::new(), + hdr_rules: HdrRules { + rejected_dolby_vision_profiles: Vec::new(), + }, + size_bands: BTreeMap::new(), + resolution_preference: vec![Resolution::R2160p, Resolution::R1080p], + source_weights: BTreeMap::new(), + } + } + + fn claims(resolution: Option, source: Option) -> NameClaims { + NameClaims { + resolution, + source, + ..NameClaims::default() + } + } + + fn probed(resolution: Resolution, source: Option) -> ProbedMedia { + ProbedMedia { + resolution, + source, + hdr: HdrFormat::Sdr, + audio_tracks: Vec::new(), + subtitle_tracks: Vec::new(), + } + } + + fn verdict(policy: &Policy, overrides: &MovieOverrides, candidate: Candidate<'_>) -> Verdict { + evaluate(policy, overrides, candidate).verdict + } + + #[test] + fn missing_pre_grab_evidence_stays_unknown() { + let policy = policy(); + let claims = claims(None, None); + let report = evaluate( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ); + + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!( + report.rules, + vec![ + RuleEvaluation::Unknown(RuleKind::Resolution), + RuleEvaluation::Unknown(RuleKind::Source), + ] + ); + } + + #[test] + fn preferred_resolutions_pass_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + + for claimed in [ClaimedResolution::P2160, ClaimedResolution::P1080] { + let claims = claims(Some(claimed), Some(ClaimedSource::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + } + for resolution in [Resolution::R2160p, Resolution::R1080p] { + let media = probed(resolution, Some(Source::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Eligible + ); + } + } + + #[test] + fn resolution_outside_root_policy_hard_fails_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedResolution::P480, Resolution::Other(480)), + (ClaimedResolution::P576, Resolution::Other(576)), + (ClaimedResolution::P720, Resolution::R720p), + ]; + + for (claimed_resolution, resolution) in cases { + let claims = claims(Some(claimed_resolution), Some(ClaimedSource::WebDl)); + let media = probed(resolution, Some(Source::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Rejected(Rule::Resolution(resolution)) + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Rejected(Rule::Resolution(resolution)) + ); + } + } + + #[test] + fn only_4k_tightens_the_root_policy_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims_1080 = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl)); + let media = probed(Resolution::R1080p, Some(Source::WebDl)); + + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims_1080)), + Verdict::Rejected(Rule::Resolution(Resolution::R1080p)) + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Waived(Rule::Resolution(Resolution::R1080p)) + ); + + let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + } + + #[test] + fn only_4k_never_expands_the_root_policy() { + let mut policy = policy(); + policy.resolution_preference = vec![Resolution::R1080p]; + let overrides = MovieOverrides { + only_4k: true, + ..MovieOverrides::default() + }; + let claims = claims(Some(ClaimedResolution::P2160), Some(ClaimedSource::WebDl)); + + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Rejected(Rule::Resolution(Resolution::R2160p)) + ); + } + + #[test] + fn every_unsafe_source_hard_fails_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Cam, Source::Cam), + (ClaimedSource::Telesync, Source::Telesync), + (ClaimedSource::Screener, Source::Screener), + ]; + + for (claimed_source, source) in cases { + let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); + let media = probed(Resolution::R2160p, Some(source)); + let expected = Verdict::Rejected(Rule::Source(source)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + expected + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + expected + ); + } + } + + #[test] + fn parsed_telesync_name_is_rejected() { + let policy = policy(); + let claims = arr_parse::parse("Movie.2024.1080p.TS-GROUP"); + + assert_eq!( + verdict( + &policy, + &MovieOverrides::default(), + Candidate::PreGrab(&claims), + ), + Verdict::Rejected(Rule::Source(Source::Telesync)) + ); + } + + #[test] + fn every_safe_source_passes_in_both_phases() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let cases = [ + (ClaimedSource::Dvd, Source::Dvd), + (ClaimedSource::Telecine, Source::Telecine), + (ClaimedSource::Hdtv, Source::Hdtv), + (ClaimedSource::WebRip, Source::WebRip), + (ClaimedSource::WebDl, Source::WebDl), + (ClaimedSource::BluRay, Source::BluRay), + (ClaimedSource::Remux, Source::Remux), + ]; + + for (claimed_source, source) in cases { + let claims = claims(Some(ClaimedResolution::P2160), Some(claimed_source)); + let media = probed(Resolution::R2160p, Some(source)); + assert_eq!( + verdict(&policy, &overrides, Candidate::PreGrab(&claims)), + Verdict::Eligible + ); + assert_eq!( + verdict(&policy, &overrides, Candidate::PostDownload(&media)), + Verdict::Eligible + ); + } + } + + #[test] + fn unknown_post_download_source_is_preserved_in_report() { + let policy = policy(); + for source in [None, Some(Source::Other)] { + let media = probed(Resolution::R2160p, source); + let report = evaluate( + &policy, + &MovieOverrides::default(), + Candidate::PostDownload(&media), + ); + + assert_eq!(report.verdict, Verdict::Eligible); + assert_eq!(report.rules[1], RuleEvaluation::Unknown(RuleKind::Source)); + } + } + + #[derive(Debug)] + struct FixedRule { + evaluation: RuleEvaluation, + } + + impl PolicyRule for FixedRule { + fn evaluate(&self, _context: &EvaluationContext<'_>) -> RuleEvaluation { + self.evaluation.clone() + } + } + + #[test] + fn hard_failure_wins_over_an_earlier_soft_failure() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let soft = FixedRule { + evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), + }; + let hard = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("hard".to_owned())), + }; + + let report = evaluate_rules(&context, &[&soft, &hard]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("hard".to_owned())) + ); + } + + #[test] + fn first_failure_of_the_deciding_kind_is_stable() { + let policy = policy(); + let overrides = MovieOverrides::default(); + let claims = claims(None, None); + let context = EvaluationContext { + policy: &policy, + overrides: &overrides, + candidate: Candidate::PreGrab(&claims), + }; + let first = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), + }; + let second = FixedRule { + evaluation: RuleEvaluation::HardFail(Rule::Other("second".to_owned())), + }; + + let report = evaluate_rules(&context, &[&first, &second]); + + assert_eq!( + report.verdict, + Verdict::Rejected(Rule::Other("first".to_owned())) + ); + } +} From e02a9aa81319f89771ef02fb8e31eb9410dd4059 Mon Sep 17 00:00:00 2001 From: "naps62-yolo (agent)" Date: Sat, 22 Aug 2026 20:18:37 +0100 Subject: [PATCH 2/2] feat(indexer): enumerate Prowlarr caps (#51) --- Cargo.lock | 18 + crates/arr-indexer/Cargo.toml | 8 + crates/arr-indexer/src/lib.rs | 383 ++++++++++++++++++ .../arr-indexer/tests/fixtures/alpha-caps.xml | 9 + .../tests/fixtures/broken-caps.xml | 2 + .../arr-indexer/tests/fixtures/indexers.json | 7 + .../tests/fixtures/text-only-caps.xml | 9 + 7 files changed, 436 insertions(+) create mode 100644 crates/arr-indexer/tests/fixtures/alpha-caps.xml create mode 100644 crates/arr-indexer/tests/fixtures/broken-caps.xml create mode 100644 crates/arr-indexer/tests/fixtures/indexers.json create mode 100644 crates/arr-indexer/tests/fixtures/text-only-caps.xml 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 @@ + + + + + + + + +