diff --git a/.impeccable/surfaces/web-src-main-ts.md b/.impeccable/surfaces/web-src-main-ts.md index e54011a..1e1b68e 100644 --- a/.impeccable/surfaces/web-src-main-ts.md +++ b/.impeccable/surfaces/web-src-main-ts.md @@ -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. diff --git a/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json new file mode 100644 index 0000000..adecf74 --- /dev/null +++ b/.sqlx/query-1f67ae5af28115d22ad5e2fde1df8ee0f4aeb615e1d29c9cae03a589c7a6bdd4.json @@ -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" +} diff --git a/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json new file mode 100644 index 0000000..41f7f09 --- /dev/null +++ b/.sqlx/query-7fe60916664f3552cd111e45586c58997079821e2521a196174f4182e41531ad.json @@ -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" +} diff --git a/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json new file mode 100644 index 0000000..91528f3 --- /dev/null +++ b/.sqlx/query-869e17a01bcf8710bb533dbe428af062e73659ee89ab4387e0a28b7d546f5be4.json @@ -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" +} diff --git a/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json new file mode 100644 index 0000000..1a98b0c --- /dev/null +++ b/.sqlx/query-ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62" +} diff --git a/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json new file mode 100644 index 0000000..4dd5fd6 --- /dev/null +++ b/.sqlx/query-e5b93151521f2d49a1f9e3c5b7ab1618554de74b444f28a89af92cde6abb47e5.json @@ -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" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 1fd1f75..38187b4 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -11,6 +11,7 @@ mod metadata; mod movies; mod owners; mod policies; +mod reclassify; mod roots; mod search; mod series; diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 351b1e5..04e8184 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -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 = + 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 = + 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; diff --git a/crates/arr-api/src/reclassify.rs b/crates/arr-api/src/reclassify.rs new file mode 100644 index 0000000..1a8fb28 --- /dev/null +++ b/crates/arr-api/src/reclassify.rs @@ -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, + rejected_rule: Option, +} + +/// 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>, +) -> 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, 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, 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))) +} diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 0c178b4..2e73d73 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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) { +pub(crate) fn verdict_columns(verdict: &Verdict) -> (&'static str, Option) { match verdict { Verdict::Eligible => ("eligible", None), Verdict::Waived(rule) => ("waived", Some(rule_name(rule))), diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index cf78ba1..6c85afa 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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 = (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 = + 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 = + 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 diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4421f0f..584da12 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -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)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index f9ab8bb..033b396 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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(); diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 354d183..d33701d 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -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, 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 { diff --git a/web/src/main.ts b/web/src/main.ts index cb3714b..410aa33 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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; 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 diff --git a/web/src/releases.ts b/web/src/releases.ts index 527bc48..c066abb 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -257,6 +257,11 @@ export async function grabRelease(movieId: number, releaseId: number): Promise | null { switch (rule) { @@ -264,11 +269,18 @@ export function waiverOverride(rule: string | null): Record | 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 };