Hard/soft fail handling and release blacklist (#87)
ci / web (push) Successful in 26s
ci / rust (push) Successful in 1m6s
e2e / e2e (push) Successful in 52s

This commit was merged in pull request #87.
This commit is contained in:
2026-08-22 23:42:36 +01:00
parent 38bd4102bb
commit 01af397a40
14 changed files with 674 additions and 43 deletions
+47 -2
View File
@@ -4,6 +4,7 @@ use arr_core::policy::{evaluate, Candidate};
use arr_core::score::score;
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
use arr_db::policy::language;
use arr_db::{blacklist, Blacklist};
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest, TvSelector, TvTarget};
use axum::extract::{Query, State};
use axum::Json;
@@ -261,6 +262,7 @@ async fn movie_releases(
.indexers()
.await
.map_err(|_| ApiError::Unavailable)?;
let blacklist = Blacklist::load(database.pool()).await?;
let policy = loaded.policy;
let overrides = loaded.overrides;
let original_language = title_language(
@@ -291,7 +293,13 @@ async fn movie_releases(
match prowlarr.search_indexer(indexer.id, &indexer_request).await {
Ok(releases) => {
for release in releases {
classified.push(classify(release, &policy, &overrides, &original_language)?);
classified.push(classify(
release,
&policy,
&overrides,
&original_language,
&blacklist,
)?);
}
}
Err(error) => {
@@ -350,6 +358,7 @@ async fn episode_releases(
.indexers()
.await
.map_err(|_| ApiError::Unavailable)?;
let blacklist = Blacklist::load(database.pool()).await?;
let mut classified = Vec::new();
for indexer in indexers {
@@ -364,6 +373,7 @@ async fn episode_releases(
&loaded.policy,
&loaded.overrides,
&original_language,
&blacklist,
)?);
}
}
@@ -446,11 +456,17 @@ fn upstream_error(error: &arr_meta::Error) -> ApiError {
}
}
/// Classify one release for the §9.3 buckets.
///
/// A blacklisted release (§6.3) is rejected whatever the policy makes of its
/// name, and says so: the operator sees why it is not offered rather than a
/// row that looks grabbable and silently is not.
fn classify(
release: SearchRelease,
policy: &Policy,
overrides: &TitleOverrides,
original_language: &Language,
blacklist: &Blacklist,
) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate(
@@ -460,7 +476,11 @@ fn classify(
Candidate::PreGrab(&parsed),
release.size,
);
let (verdict, rule) = verdict(&evaluation.verdict);
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned()))
} else {
verdict(&evaluation.verdict)
};
let score = score(
policy,
Candidate::PreGrab(&parsed),
@@ -686,6 +706,30 @@ mod tests {
.find(|release| release["guid"] == "good")
.expect("eligible");
assert_eq!(eligible["score"], 0);
// §6.3: once that release has hard-failed, the manual view must not
// keep offering it as a clean match — it is rejected, and says why.
blacklist::add(
state.database().expect("database").pool(),
None,
"Dune.Part.Two.2024.2160p.WEB-DL",
"dolby_vision_profile",
)
.await
.expect("blacklist");
let releases: Vec<serde_json::Value> =
reqwest::get(format!("{base}/api/releases?movie_id=1"))
.await
.expect("releases")
.json()
.await
.expect("json");
let blacklisted = releases
.iter()
.find(|release| release["guid"] == "good")
.expect("blacklisted");
assert_eq!(blacklisted["verdict"], "rejected");
assert_eq!(blacklisted["rule"], "blacklisted");
}
#[tokio::test]
@@ -796,6 +840,7 @@ mod tests {
&policy,
&TitleOverrides::default(),
&Language::Other("en".into()),
&Blacklist::default(),
)
.expect("classified release");