feat: let a size rejection be waived
A release below §5.5's floor was rejected with no way through, so a policy wrong about one title left three Rick and Morty S09 packs visible and none grabbable. `allow_below_floor` relaxes the floor for one title into a soft fail, never a pass: the release is waived, so automatic grabbing still skips it and the import records a §5.7 waiver. The deck offers the one click on a rejected row where the rule has an override, which is exactly what §9.3's override is for. Stored verdicts are re-derived when a title's overrides change — the deck and the daemon's grab gate both read that column, so without it the row the operator just acted on would keep reading `rejected`. Closes #210 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
405358bf24
commit
9fee07f080
@@ -11,6 +11,7 @@ mod metadata;
|
||||
mod movies;
|
||||
mod owners;
|
||||
mod policies;
|
||||
mod reclassify;
|
||||
mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
|
||||
@@ -240,10 +240,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
||||
};
|
||||
if object
|
||||
.keys()
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio")
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
|
||||
{
|
||||
return Err(ApiError::Invalid(
|
||||
"overrides supports only only_4k and allow_english_audio".into(),
|
||||
"overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
|
||||
));
|
||||
}
|
||||
if object.values().any(|value| !value.is_boolean()) {
|
||||
@@ -406,8 +406,11 @@ pub async fn update(
|
||||
if title.trim().is_empty() {
|
||||
return Err(ApiError::Invalid("title cannot be empty".into()));
|
||||
}
|
||||
let overrides = input.overrides.unwrap_or(current.overrides);
|
||||
let overrides = input.overrides.unwrap_or(current.overrides.clone());
|
||||
validate_overrides(&overrides)?;
|
||||
// Verdicts already stored for this title were reached under the old
|
||||
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
|
||||
let overrides_changed = overrides != current.overrides;
|
||||
let overrides =
|
||||
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||
let title = title.trim();
|
||||
@@ -430,6 +433,9 @@ pub async fn update(
|
||||
sqlx::query!("UPDATE movies SET title = ?, year = ?, original_language = ?, root_id = ?, wanted = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, wanted, blocked, overrides, id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
if overrides_changed {
|
||||
crate::reclassify::movie(&state, id).await?;
|
||||
}
|
||||
Ok(Json(load_movie(&state, id).await?))
|
||||
}
|
||||
|
||||
@@ -1135,6 +1141,114 @@ mod tests {
|
||||
assert_eq!(files[0]["probed"]["resolution"], "1080p");
|
||||
}
|
||||
|
||||
/// #210 on the movie deck: relaxing §5.5's floor for one title moves its
|
||||
/// size rejections into the waived bucket and no further — a waiver is
|
||||
/// never picked automatically (§9.3), only offered.
|
||||
#[tokio::test]
|
||||
async fn allow_below_floor_moves_a_size_rejection_to_waived_not_eligible() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let created: serde_json::Value = reqwest::Client::new()
|
||||
.post(format!("{base}/api/movies"))
|
||||
.json(&serde_json::json!({
|
||||
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
||||
"original_language": "en", "root_id": 1, "overrides": {}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create movie")
|
||||
.json()
|
||||
.await
|
||||
.expect("movie json");
|
||||
let movie_id = created["id"].as_i64().expect("id");
|
||||
|
||||
// 2 GiB at 1080p, under the movie policy's 3 GiB floor.
|
||||
let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 2_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
|
||||
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'size') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("association");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/movies/{movie_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(releases[0]["verdict"], "waived");
|
||||
}
|
||||
|
||||
/// A blacklisted release (§6.3) is not a policy opinion, so no override
|
||||
/// re-opens it.
|
||||
#[tokio::test]
|
||||
async fn an_override_never_revives_a_blacklisted_release() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let pool = state.database().expect("database").pool();
|
||||
let movie = add_movie(&base, 693_134, 1).await;
|
||||
let movie_id = movie["id"].as_i64().expect("id");
|
||||
|
||||
let name = "Dune Part Two 2024 1080p WEB-DL x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 8_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
|
||||
VALUES (7, 'g', ?, ?, 40, 'url', ?, 0, 'rejected', 'blacklisted') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
||||
.bind(movie_id)
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("association");
|
||||
|
||||
reqwest::Client::new()
|
||||
.patch(format!("{base}/api/movies/{movie_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_below_floor": true, "only_4k": false}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/movies/{movie_id}/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases[0]["verdict"], "rejected");
|
||||
assert_eq!(releases[0]["rejected_rule"], "blacklisted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn crud_preserves_intent_and_overrides() {
|
||||
let (_dir, _state, base) = application().await;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
//! Stored verdicts, re-derived when a title's overrides change (§9.3).
|
||||
//!
|
||||
//! A release's verdict is stamped once, by the search that found it. Both the
|
||||
//! deck and the daemon's manual-grab gate read that stored column, so an
|
||||
//! override written from the deck's one-click waive would change nothing
|
||||
//! until the next sweep — the row the operator just acted on would keep
|
||||
//! reading `rejected` and the grab would be refused.
|
||||
//!
|
||||
//! So the rules run again here, over the releases already attached to the
|
||||
//! title. This is the same correction the daemon makes when a grab turns out
|
||||
//! to be blacklisted: fix the row now rather than leave §9.3 offering, or
|
||||
//! refusing, something that is no longer true.
|
||||
//!
|
||||
//! The blacklist (§6.3) is not a policy opinion, so a row rejected by it is
|
||||
//! left exactly as it is.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::claimed_episode_count;
|
||||
use arr_core::{Language, ParsedRelease, Policy, TitleOverrides};
|
||||
use arr_db::blacklist;
|
||||
|
||||
use crate::movies::{pool, ApiError};
|
||||
use crate::search::verdict_columns;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// One stored release, as much of it as re-evaluation needs.
|
||||
struct Stored {
|
||||
id: i64,
|
||||
size: i64,
|
||||
parsed: serde_json::Value,
|
||||
verdict: Option<String>,
|
||||
rejected_rule: Option<String>,
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one movie.
|
||||
///
|
||||
/// A movie with no stored original language is left alone, the same rule the
|
||||
/// daemon's search applies: without it the required-audio rule has nothing to
|
||||
/// compare, and guessing would move verdicts on worse evidence than the ones
|
||||
/// already there.
|
||||
pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = movie_language(state, movie_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.movie_policy(movie_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
JOIN movie_releases mr ON mr.release_id = r.id
|
||||
WHERE mr.movie_id = ?
|
||||
"#,
|
||||
movie_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Re-evaluate every release attached to one series, episode decks and season
|
||||
/// decks alike — overrides sit on the series (§5.1), so one change moves both.
|
||||
pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiError> {
|
||||
let Some(language) = series_language(state, series_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(loaded) = state
|
||||
.database()
|
||||
.ok_or(ApiError::Unavailable)?
|
||||
.series_policy(series_id)
|
||||
.await
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let releases = sqlx::query_as!(
|
||||
Stored,
|
||||
r#"
|
||||
SELECT r.id AS "id!: i64",
|
||||
r.size AS "size!: i64",
|
||||
r.parsed AS "parsed!: serde_json::Value",
|
||||
r.verdict,
|
||||
r.rejected_rule
|
||||
FROM releases r
|
||||
WHERE r.id IN (
|
||||
SELECT er.release_id
|
||||
FROM episode_releases er
|
||||
JOIN episodes e ON e.id = er.episode_id
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
WHERE se.series_id = ?
|
||||
UNION
|
||||
SELECT sr.release_id
|
||||
FROM season_releases sr
|
||||
JOIN seasons se ON se.id = sr.season_id
|
||||
WHERE se.series_id = ?
|
||||
)
|
||||
"#,
|
||||
series_id,
|
||||
series_id
|
||||
)
|
||||
.fetch_all(pool(state)?)
|
||||
.await?;
|
||||
|
||||
// A size band describes one episode (§5.5), so a pack's verdict needs the
|
||||
// same divisor the deck scores it with.
|
||||
let lengths = crate::series::season_lengths(state, series_id).await?;
|
||||
apply(
|
||||
state,
|
||||
&releases,
|
||||
&loaded.policy,
|
||||
&loaded.overrides,
|
||||
&language,
|
||||
Some(&lengths),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn apply(
|
||||
state: &AppState,
|
||||
releases: &[Stored],
|
||||
policy: &Policy,
|
||||
overrides: &TitleOverrides,
|
||||
original_language: &Language,
|
||||
season_lengths: Option<&BTreeMap<u32, u32>>,
|
||||
) -> Result<(), ApiError> {
|
||||
for release in releases {
|
||||
if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
|
||||
continue;
|
||||
}
|
||||
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
|
||||
.map_err(|error| ApiError::Database(error.to_string()))?;
|
||||
let episodes = season_lengths.map_or(1, |lengths| {
|
||||
claimed_episode_count(parsed.episode.as_ref(), lengths)
|
||||
});
|
||||
// A stored zero is a release the indexer gave no size for, and a
|
||||
// release with no size has nothing to say about its band — the same
|
||||
// reading the deck's rescore takes.
|
||||
let size = u64::try_from(release.size).ok().filter(|size| *size > 0);
|
||||
let evaluation = evaluate(
|
||||
policy,
|
||||
overrides,
|
||||
original_language,
|
||||
Candidate::PreGrab(&parsed),
|
||||
size,
|
||||
episodes,
|
||||
);
|
||||
// `releases` allows a rule name only on a rejected row
|
||||
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
|
||||
// which is also how the daemon writes a waiver.
|
||||
let (verdict, rule) = verdict_columns(&evaluation.verdict);
|
||||
let rule = if verdict == "rejected" { rule } else { None };
|
||||
if release.verdict.as_deref() == Some(verdict) && release.rejected_rule == rule {
|
||||
continue;
|
||||
}
|
||||
sqlx::query!(
|
||||
"UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
verdict,
|
||||
rule,
|
||||
release.id
|
||||
)
|
||||
.execute(pool(state)?)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn movie_language(state: &AppState, movie_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM movies WHERE id = ?",
|
||||
movie_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
|
||||
async fn series_language(state: &AppState, series_id: i64) -> Result<Option<Language>, ApiError> {
|
||||
let stored = sqlx::query_scalar!(
|
||||
"SELECT original_language FROM series WHERE id = ?",
|
||||
series_id
|
||||
)
|
||||
.fetch_optional(pool(state)?)
|
||||
.await?
|
||||
.flatten();
|
||||
Ok(stored.map(|value| arr_db::policy::language(&value)))
|
||||
}
|
||||
@@ -728,7 +728,7 @@ fn classify(
|
||||
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
|
||||
("rejected", Some(blacklist::RULE.to_owned()))
|
||||
} else {
|
||||
verdict(&evaluation.verdict)
|
||||
verdict_columns(&evaluation.verdict)
|
||||
};
|
||||
let score = score(
|
||||
policy,
|
||||
@@ -778,7 +778,7 @@ fn classify(
|
||||
})
|
||||
}
|
||||
|
||||
fn verdict(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option<String>) {
|
||||
match verdict {
|
||||
Verdict::Eligible => ("eligible", None),
|
||||
Verdict::Waived(rule) => ("waived", Some(rule_name(rule))),
|
||||
|
||||
@@ -184,10 +184,10 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
|
||||
};
|
||||
if object
|
||||
.keys()
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio")
|
||||
.any(|key| key != "only_4k" && key != "allow_english_audio" && key != "allow_below_floor")
|
||||
{
|
||||
return Err(ApiError::Invalid(
|
||||
"overrides supports only only_4k and allow_english_audio".into(),
|
||||
"overrides supports only only_4k, allow_english_audio and allow_below_floor".into(),
|
||||
));
|
||||
}
|
||||
if object.values().any(|value| !value.is_boolean()) {
|
||||
@@ -561,8 +561,11 @@ pub async fn update(
|
||||
if title.trim().is_empty() {
|
||||
return Err(ApiError::Invalid("title cannot be empty".into()));
|
||||
}
|
||||
let overrides = input.overrides.unwrap_or(current.overrides);
|
||||
let overrides = input.overrides.unwrap_or(current.overrides.clone());
|
||||
validate_overrides(&overrides)?;
|
||||
// Verdicts already stored for this series were reached under the old
|
||||
// overrides; §9.3's deck and the grab gate both read them (`reclassify`).
|
||||
let overrides_changed = overrides != current.overrides;
|
||||
let overrides =
|
||||
serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?;
|
||||
let title = title.trim();
|
||||
@@ -578,6 +581,9 @@ pub async fn update(
|
||||
sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
if overrides_changed {
|
||||
crate::reclassify::series(&state, id).await?;
|
||||
}
|
||||
Ok(Json(load_series(&state, id).await?))
|
||||
}
|
||||
|
||||
@@ -1869,6 +1875,129 @@ mod tests {
|
||||
response.json().await.expect("season json")
|
||||
}
|
||||
|
||||
/// The production case behind #210: every pack of a season is under
|
||||
/// §5.5's per-episode floor, so the deck holds three candidates and
|
||||
/// nothing is grabbable. Writing `allow_below_floor` turns the
|
||||
/// rejections into waivers — offered, never automatic.
|
||||
#[tokio::test]
|
||||
async fn allow_below_floor_turns_a_size_rejected_pack_into_a_waiver() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
let episodes: Vec<serde_json::Value> = (1..=10)
|
||||
.map(|number| {
|
||||
serde_json::json!({
|
||||
"number": number, "title": format!("Episode {number}"),
|
||||
"air_date": "2025-01-01"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let season = add_season(&base, series_id, 9, serde_json::json!(episodes)).await;
|
||||
let season_id = season["id"].as_i64().expect("season id");
|
||||
|
||||
// Ten episodes in 8 GiB: 0.8 GiB each, under the 1 GiB 1080p floor.
|
||||
let pool = state.database().expect("database").pool();
|
||||
let name = "Bluey S09 1080p WEB-DL x264-GROUP";
|
||||
let parsed = arr_parse::parse(name);
|
||||
let size = 8_i64 * (1 << 30);
|
||||
let release_id = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict, rejected_rule)
|
||||
VALUES (7, 'pack', ?, ?, 50, 'url', ?, 0, 'rejected', 'size') RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(size)
|
||||
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("release");
|
||||
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
||||
.bind(season_id)
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("association");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_below_floor": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{:?}",
|
||||
response.text().await
|
||||
);
|
||||
let updated: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}"))
|
||||
.await
|
||||
.expect("series")
|
||||
.json()
|
||||
.await
|
||||
.expect("series json");
|
||||
assert_eq!(updated["overrides"]["allow_below_floor"], true);
|
||||
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases.len(), 1);
|
||||
assert_eq!(
|
||||
releases[0]["verdict"], "waived",
|
||||
"a waived grab stays a waiver; nothing here makes it eligible"
|
||||
);
|
||||
// The row's rule name goes with the rejection; what survives is the
|
||||
// dashed `waived` verdict the deck reads (§9.3).
|
||||
assert!(releases[0]["rejected_rule"].is_null());
|
||||
|
||||
// The grab the deck's one click sends is now accepted.
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{base}/api/series/{series_id}/seasons/9/releases/{release_id}/grab"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.expect("grab");
|
||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||
|
||||
// And clearing the override puts the floor back.
|
||||
reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("clear override");
|
||||
let releases: Vec<serde_json::Value> =
|
||||
reqwest::get(format!("{base}/api/series/{series_id}/seasons/9/releases"))
|
||||
.await
|
||||
.expect("releases")
|
||||
.json()
|
||||
.await
|
||||
.expect("releases json");
|
||||
assert_eq!(releases[0]["verdict"], "rejected");
|
||||
assert_eq!(releases[0]["rejected_rule"], "size");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overrides_reject_a_key_the_policy_engine_has_no_rule_for() {
|
||||
let (_dir, state, base) = application().await;
|
||||
let root_id = tv_root(&state, "main").await;
|
||||
let series = add_series(&base, root_id, false).await;
|
||||
let series_id = series["id"].as_i64().expect("id");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.patch(format!("{base}/api/series/{series_id}"))
|
||||
.json(&serde_json::json!({"overrides": {"allow_anything": true}}))
|
||||
.send()
|
||||
.await
|
||||
.expect("override");
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
/// §9.6: the three stored artwork fields come off the series detail
|
||||
/// response at add time — the same call that used to fetch only the
|
||||
/// TVDB id — so a series added today has a poster before tomorrow's
|
||||
|
||||
Reference in New Issue
Block a user