@@ -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.
|
||||
|
||||
+182
@@ -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"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE releases SET verdict = ?, rejected_rule = ? WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ad652cad9ca76a349b9257f7036e4af24a93f62b5c04b1a67ad8704f31c39d62"
|
||||
}
|
||||
+74
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user