Merge #210: let a size rejection be waived

Closes #210
This commit is contained in:
Miguel Palhas
2026-08-24 22:16:01 +01:00
16 changed files with 1098 additions and 20 deletions
+9 -3
View File
@@ -29,9 +29,15 @@ reads the library as derived status, not flags.
counts behind cyan show/hide toggles. Release name is a secondary
truncated line, full string plus indexer/date/bytes on row expand. Every
waived/rejected row carries a verdict chip naming the rule. Grab is its
own control per row; on a waived row it is "waive + grab" and writes the
per-title override (rule→override mapping in releases.ts — bookkeeping,
never policy; verdicts always come from the API). Esc steps back one
own control per row; where the rule that failed has an override it reads
"waive + grab" and writes the per-title override (rule→override mapping in
releases.ts — bookkeeping, never policy; verdicts always come from the
API). A rejected row offers it too — §9.3's one click exists precisely for
the rule the operator disagrees with, and a size rejection (#210) is only
ever rejected. What the click produces stays a waiver: the row re-reads as
a dashed WAIVED, never eligible. When ELIGIBLE is empty and something is
waivable, its "none" line carries the count, so the way out is not folded
inside a collapsed bucket. Esc steps back one
layer: deck → search results → board.
- Removal (§7.4, issue 104): REMOVE is a quiet control in the deck head,
never a row affordance — a dense list is the wrong place for a delete.
@@ -0,0 +1,182 @@
{
"db_name": "SQLite",
"query": "\n SELECT s.overrides AS \"overrides!: String\",\n r.id AS \"root_id!: i64\",\n r.kind AS \"root_kind!: String\",\n r.audience AS \"root_audience!: String\",\n r.path AS \"root_path!: String\",\n p.id AS \"policy_id!: i64\",\n p.name AS \"policy_name!: String\",\n p.required_audio AS \"required_audio!: String\",\n p.dub_blacklist AS \"dub_blacklist!: String\",\n p.hdr_rules AS \"hdr_rules!: String\",\n p.size_bands AS \"size_bands!: String\",\n p.resolution_pref AS \"resolution_pref!: String\",\n p.source_weights AS \"source_weights!: String\",\n p.score_weights AS \"score_weights!: String\"\n FROM series s\n JOIN roots r ON r.id = s.root_id\n JOIN policies p ON p.id = r.policy_id\n WHERE s.id = ?\n ",
"describe": {
"columns": [
{
"name": "overrides!: String",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "overrides"
}
}
},
{
"name": "root_id!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "roots",
"name": "id"
}
}
},
{
"name": "root_kind!: String",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "kind"
}
}
},
{
"name": "root_audience!: String",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "audience"
}
}
},
{
"name": "root_path!: String",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "roots",
"name": "path"
}
}
},
{
"name": "policy_id!: i64",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "policies",
"name": "id"
}
}
},
{
"name": "policy_name!: String",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "name"
}
}
},
{
"name": "required_audio!: String",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "required_audio"
}
}
},
{
"name": "dub_blacklist!: String",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "dub_blacklist"
}
}
},
{
"name": "hdr_rules!: String",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "hdr_rules"
}
}
},
{
"name": "size_bands!: String",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "size_bands"
}
}
},
{
"name": "resolution_pref!: String",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "resolution_pref"
}
}
},
{
"name": "source_weights!: String",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "source_weights"
}
}
},
{
"name": "score_weights!: String",
"ordinal": 13,
"type_info": "Text",
"origin": {
"Table": {
"table": "policies",
"name": "score_weights"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n WHERE r.id IN (\n SELECT er.release_id\n FROM episode_releases er\n JOIN episodes e ON e.id = er.episode_id\n JOIN seasons se ON se.id = e.season_id\n WHERE se.series_id = ?\n UNION\n SELECT sr.release_id\n FROM season_releases sr\n JOIN seasons se ON se.id = sr.season_id\n WHERE se.series_id = ?\n )\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT original_language FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "original_language",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "series",
"name": "original_language"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.id AS \"id!: i64\",\n r.size AS \"size!: i64\",\n r.parsed AS \"parsed!: serde_json::Value\",\n r.verdict,\n r.rejected_rule\n FROM releases r\n JOIN movie_releases mr ON mr.release_id = r.id\n WHERE mr.movie_id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "id"
}
}
},
{
"name": "size!: i64",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "releases",
"name": "size"
}
}
},
{
"name": "parsed!: serde_json::Value",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "parsed"
}
}
},
{
"name": "verdict",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "verdict"
}
}
},
{
"name": "rejected_rule",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "releases",
"name": "rejected_rule"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5"
}
+1
View File
@@ -11,6 +11,7 @@ mod metadata;
mod movies;
mod owners;
mod policies;
mod reclassify;
mod roots;
mod search;
mod series;
+117 -3
View File
@@ -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;
+210
View File
@@ -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)))
}
+2 -2
View File
@@ -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))),
+132 -3
View File
@@ -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
+8
View File
@@ -237,6 +237,14 @@ pub struct Policy {
pub struct TitleOverrides {
pub only_4k: bool,
pub allow_english_audio: bool,
/// Take a release below its size band's floor on this title (§5.5).
///
/// No band is right for every title, and the floor is a hard reject, so
/// a title the band is wrong about has nothing grabbable at all. This
/// relaxes the floor to a soft fail rather than removing it: the release
/// is waived, never eligible, so it stays a deliberate manual grab and
/// imports on the record as a §5.7 waiver.
pub allow_below_floor: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+141
View File
@@ -224,6 +224,12 @@ impl PolicyRule for SourceRule {
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
/// not this rule's.
///
/// `allow_below_floor` softens the floor for one title rather than lifting
/// it: a below-floor release becomes a waiver, so it is never picked
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
/// [`ResolutionRule`], where an override moves a failure between hard and
/// soft and never makes the rule stop applying.
#[derive(Clone, Copy, Debug, Default)]
pub struct SizeRule;
@@ -236,6 +242,9 @@ impl PolicyRule for SizeRule {
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
{
None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size)
}
Some(true) => RuleEvaluation::HardFail(Rule::Size),
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
}
@@ -655,6 +664,138 @@ mod tests {
);
}
/// A policy with one 1080p band, so the floor has something to say.
fn banded_policy() -> Policy {
Policy {
size_bands: BTreeMap::from([(
Resolution::R1080p,
crate::SizeBand {
floor_bytes: 2 << 30,
target_bytes: 4 << 30,
penalty_points_per_gib_over: 600,
},
)]),
resolution_preference: vec![Resolution::R1080p],
..policy()
}
}
#[test]
fn a_below_floor_release_is_rejected_without_the_override() {
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
let evaluation = evaluate(
&banded_policy(),
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
);
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
}
#[test]
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
let policy = banded_policy();
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Pre-grab the deck offers it, and only as a waiver: `waived` is
// never picked automatically (§9.3), so the operator still decides.
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(1 << 30),
1,
)
.verdict,
Verdict::Waived(Rule::Size)
);
// And the import records the waiver rather than hard-failing (§5.7).
let media = probed(Resolution::R1080p, Some(Source::WebDl));
assert_eq!(
evaluate(
&policy,
&overrides,
&en(),
Candidate::PostDownload(&media),
Some(1 << 30),
1,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
let overrides = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
assert_eq!(
evaluate(
&banded_policy(),
&overrides,
&en(),
Candidate::PreGrab(&claims),
Some(4 << 30),
1,
)
.verdict,
Verdict::Eligible
);
}
/// §5.5: the floor takes the per-episode figure, so the override that
/// relaxes it has to travel the same divisor.
#[test]
fn allow_below_floor_waives_a_pack_measured_per_episode() {
let policy = banded_policy();
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// Ten episodes at 1 GiB each: over the floor in total, under it per
// episode, which is the figure the floor compares.
let size = Some(10 << 30);
assert_eq!(
evaluate(
&policy,
&TitleOverrides::default(),
&en(),
Candidate::PreGrab(&claims),
size,
10,
)
.verdict,
Verdict::Rejected(Rule::Size)
);
assert_eq!(
evaluate(
&policy,
&TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
},
&en(),
Candidate::PreGrab(&claims),
size,
10,
)
.verdict,
Verdict::Waived(Rule::Size)
);
}
#[test]
fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy();
+73
View File
@@ -193,6 +193,7 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -268,6 +269,75 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
root_audience: row.root_audience,
root_path: row.root_path,
}))
}
/// The policy attached to one series' root, with the series' own
/// overrides (§5.1).
///
/// `None` when the series does not exist.
///
/// # Errors
///
/// If the query fails, or a policy column does not hold the JSON its
/// migration promises.
pub async fn series_policy(&self, series_id: i64) -> Result<Option<TitlePolicy>, PolicyError> {
let row = sqlx::query!(
r#"
SELECT s.overrides AS "overrides!: String",
r.id AS "root_id!: i64",
r.kind AS "root_kind!: String",
r.audience AS "root_audience!: String",
r.path AS "root_path!: String",
p.id AS "policy_id!: i64",
p.name AS "policy_name!: String",
p.required_audio AS "required_audio!: String",
p.dub_blacklist AS "dub_blacklist!: String",
p.hdr_rules AS "hdr_rules!: String",
p.size_bands AS "size_bands!: String",
p.resolution_pref AS "resolution_pref!: String",
p.source_weights AS "source_weights!: String",
p.score_weights AS "score_weights!: String"
FROM series s
JOIN roots r ON r.id = s.root_id
JOIN policies p ON p.id = r.policy_id
WHERE s.id = ?
"#,
series_id
)
.fetch_optional(self.pool())
.await?;
let Some(row) = row else {
return Ok(None);
};
let overrides: OverridesJson = json("overrides", &row.overrides)?;
let policy = PolicyColumns {
id: row.policy_id,
name: row.policy_name,
required_audio: row.required_audio,
dub_blacklist: row.dub_blacklist,
hdr_rules: row.hdr_rules,
size_bands: row.size_bands,
resolution_pref: row.resolution_pref,
source_weights: row.source_weights,
score_weights: row.score_weights,
}
.to_policy()?;
Ok(Some(TitlePolicy {
policy,
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -337,6 +407,7 @@ impl Db {
overrides: TitleOverrides {
only_4k: overrides.only_4k,
allow_english_audio: overrides.allow_english_audio,
allow_below_floor: overrides.allow_below_floor,
},
root_id: row.root_id,
root_kind: row.root_kind,
@@ -428,6 +499,8 @@ struct OverridesJson {
only_4k: bool,
#[serde(default)]
allow_english_audio: bool,
#[serde(default)]
allow_below_floor: bool,
}
fn gib(value: u64) -> u64 {
+25 -9
View File
@@ -50,6 +50,7 @@ import {
movieFiles,
movieReleases,
movieSearchState,
overridable,
probedAttributeTags,
queueSearch,
removeMovie,
@@ -59,7 +60,6 @@ import {
totalSize,
type WaiveOutcome,
waiveAndGrab,
waiverOverride,
} from "./releases";
import { currentRoute, navigate, type Route } from "./router";
import {
@@ -1352,7 +1352,7 @@ function movieMain(views: HideableView[]): MovieView {
if (!movie) {
return { kind: "error", detail: "page closed", overrideWritten: false };
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrab(movie.id, release.id, release.rejected_rule);
}
const outcome = await grabRelease(movie.id, release.id);
@@ -2091,7 +2091,8 @@ function removePanel(subject: RemoveSubject, actions: RemoveActions): HTMLElemen
interface ReleaseActions {
reload: () => Promise<void>;
notify: (text: string, tone?: "fault") => void;
/** One click on grab — a plain grab, or waive-then-grab on a waived row. */
/** One click on grab — a plain grab, or waive-then-grab where the rule
* that failed has an override (§9.3). */
grab: (
release: MovieRelease,
bucket: "eligible" | "waived" | "rejected",
@@ -2155,10 +2156,16 @@ function paintBuckets(dom: BucketsDom, releases: MovieRelease[], actions: Releas
dom.eligible.rows.append(releaseRow(release, "eligible", scoreStop, actions));
}
} else {
// §9.3: over-strict filters must be visible, not silently absent
// §9.3: over-strict filters must be visible, not silently absent — and
// where a rule can be waived, the count says so rather than leaving the
// way out folded inside a collapsed bucket.
const waivable = releases.filter(overridable).length;
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "none — every candidate was waived or rejected by policy";
none.textContent =
waivable > 0
? `none — every candidate was waived or rejected by policy; ${waivable} can be waived`
: "none — every candidate was waived or rejected by policy";
dom.eligible.rows.append(none);
}
for (const name of ["waived", "rejected"] as const) {
@@ -2295,8 +2302,14 @@ function releaseRow(
);
}
if (bucket !== "eligible") {
// A rejected row always names its rule; a waived one cannot — the
// `releases` CHECK allows `rejected_rule` only on a rejection. Say
// `waived` plainly rather than calling a classified row unclassified.
const verdict = release.rejected_rule
? `${bucket} · ${ruleLabel(release.rejected_rule)}`
: bucket;
line.append(
chip(`${bucket} · ${ruleLabel(release.rejected_rule)}`, (span) => {
chip(verdict, (span) => {
span.dataset.verdict = bucket;
}),
);
@@ -2313,11 +2326,14 @@ function releaseRow(
note.setAttribute("role", "status");
note.hidden = true;
if (bucket !== "rejected") {
// A rejected row is normally inert, but §9.3's one click is exactly for
// the rule the operator disagrees with: where an override exists, the row
// offers it and the grab that follows stays a waiver.
const writesOverride = bucket !== "eligible" && overridable(release);
if (bucket !== "rejected" || writesOverride) {
const grab = document.createElement("button");
grab.type = "button";
grab.className = "control rel-grab";
const writesOverride = bucket === "waived" && waiverOverride(release.rejected_rule) !== null;
grab.textContent = writesOverride ? "waive + grab" : "grab";
grab.addEventListener("click", () => {
grab.disabled = true;
@@ -2829,7 +2845,7 @@ function tvReleasesMain(): TvReleasesView {
if (!current) {
return Promise.resolve({ kind: "error", detail: "deck closed", overrideWritten: false });
}
if (bucket === "waived") {
if (bucket !== "eligible") {
return waiveAndGrabTv(current.seriesId, current.target, release.id, release.rejected_rule);
}
return current.target
+12
View File
@@ -257,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise<A
* The per-title override one click on a waived row writes (§5.2, §9.3).
* Mapping a rule name to its override is bookkeeping, not policy — the
* verdict itself always comes from the API.
*
* `size` relaxes §5.5's floor for this title only, and only into a waiver:
* the release stays out of automatic grabbing and imports on the record as
* a §5.7 waiver. No band is right for every title, which is why the
* override exists at all.
*/
export function waiverOverride(rule: string | null): Record<string, unknown> | null {
switch (rule) {
@@ -264,11 +269,18 @@ export function waiverOverride(rule: string | null): Record<string, unknown> | n
return { allow_english_audio: true };
case "resolution":
return { only_4k: false };
case "size":
return { allow_below_floor: true };
default:
return null;
}
}
/** Whether one click on this row has an override to write (§9.3). */
export function overridable(release: { rejected_rule: string | null }): boolean {
return waiverOverride(release.rejected_rule) !== null;
}
export type WaiveOutcome =
| { kind: "done"; overrideWritten: boolean }
| { kind: "error"; detail: string; overrideWritten: boolean };