Merge remote-tracking branch 'origin/main' into issue/29-movie-api
This commit is contained in:
Generated
+21
@@ -52,6 +52,9 @@ version = "0.1.0"
|
||||
[[package]]
|
||||
name = "arr-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arr-parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arr-daemon"
|
||||
@@ -92,6 +95,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"
|
||||
@@ -1318,6 +1329,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"
|
||||
|
||||
@@ -7,6 +7,7 @@ repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
arr-parse.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+35
-16
@@ -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<arr_parse::Resolution> 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<arr_parse::Source> 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<Rule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ParsedRelease {
|
||||
pub resolution: Option<Resolution>,
|
||||
pub source: Option<Source>,
|
||||
pub hdr: Vec<HdrClaim>,
|
||||
pub audio_languages: Vec<Language>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Release {
|
||||
pub id: ReleaseId,
|
||||
|
||||
@@ -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<Resolution> {
|
||||
match self {
|
||||
Self::PreGrab(claims) => claims.resolution.map(Resolution::from),
|
||||
Self::PostDownload(media) => Some(media.resolution),
|
||||
}
|
||||
}
|
||||
|
||||
fn source(&self) -> Option<Source> {
|
||||
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<RuleEvaluation>,
|
||||
}
|
||||
|
||||
/// 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<RuleEvaluation> =
|
||||
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<ClaimedResolution>, source: Option<ClaimedSource>) -> NameClaims {
|
||||
NameClaims {
|
||||
resolution,
|
||||
source,
|
||||
..NameClaims::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn probed(resolution: Resolution, source: Option<Source>) -> 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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<error code="100" description="Indexer unavailable" />
|
||||
@@ -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 }
|
||||
]
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user