From 2286cba86235362a4f5609430b2a6ae9a8430d86 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 20:10:23 +0100 Subject: [PATCH] feat(core): score season packs per episode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A size band describes one episode (DESIGN.md §5.5), so both the target penalty and the floor now compare a release's size divided by the number of episodes it covers. The caller supplies the count — arr-core has no IO. Movies and unknown counts divide by one, so movie scoring is unchanged and pinned by test. Closes #180 Co-Authored-By: Claude Fable 5 --- ...0c22ee77649a368081580a1f13ff29288325d.json | 33 +++ ...1fcd279d4542e6aa1f8d8d80bf96746e1f33.json} | 26 +- ...e23d8618487133ff69ee2c88868dc87d35a51.json | 33 +++ crates/arr-api/src/movies.rs | 23 +- crates/arr-api/src/search.rs | 21 +- crates/arr-api/src/series.rs | 38 ++- crates/arr-core/src/policy.rs | 27 +- crates/arr-core/src/score.rs | 277 +++++++++++++++++- crates/arr-daemon/src/grab.rs | 54 +++- crates/arr-daemon/src/import.rs | 2 + crates/arr-db/src/policy.rs | 7 +- 11 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 .sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json rename .sqlx/{query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json => query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json} (66%) create mode 100644 .sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json diff --git a/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json new file mode 100644 index 0000000..1ae4e8d --- /dev/null +++ b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d" +} diff --git a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json similarity index 66% rename from .sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json rename to .sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json index ea47275..86b77a4 100644 --- a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json +++ b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json @@ -1,11 +1,22 @@ { "db_name": "SQLite", - "query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", + "query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", "describe": { "columns": [ { - "name": "series_title!: String", + "name": "series_id!: i64", "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } + }, + { + "name": "series_title!: String", + "ordinal": 1, "type_info": "Text", "origin": { "Table": { @@ -16,7 +27,7 @@ }, { "name": "series_tvdb_id", - "ordinal": 1, + "ordinal": 2, "type_info": "Integer", "origin": { "Table": { @@ -27,7 +38,7 @@ }, { "name": "original_language", - "ordinal": 2, + "ordinal": 3, "type_info": "Text", "origin": { "Table": { @@ -38,7 +49,7 @@ }, { "name": "season_number!: i64", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer", "origin": { "Table": { @@ -49,7 +60,7 @@ }, { "name": "episode_number!: i64", - "ordinal": 4, + "ordinal": 5, "type_info": "Integer", "origin": { "Table": { @@ -63,6 +74,7 @@ "Right": 1 }, "nullable": [ + false, false, true, true, @@ -70,5 +82,5 @@ false ] }, - "hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d" + "hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33" } diff --git a/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json new file mode 100644 index 0000000..8373dad --- /dev/null +++ b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 9cbf8b8..351b1e5 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeMap; + use arr_core::policy::Candidate; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{ParsedRelease, Policy}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -602,7 +604,7 @@ pub async fn releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)? .policy; - rescore(&mut releases, &policy)?; + rescore(&mut releases, &policy, None)?; Ok(Json(releases)) } @@ -611,18 +613,30 @@ pub async fn releases( /// daemon's winner pick; the deck the user sees must reflect policy tuning /// made since the release was last searched, not whatever was true then. /// +/// `season_lengths` maps season number to episode count for the deck's series +/// — a size band describes one episode, so a pack's size is divided by how +/// many it covers. `None` is the movie decks: one release is one film, +/// whatever episode-shaped noise its name parses to. +/// /// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// policy weights in the thousands), so the `i64` -> `f64` cast into the /// column's storage type is exact. #[allow(clippy::cast_precision_loss)] -pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> { +pub(crate) fn rescore( + releases: &mut [Release], + policy: &Policy, + season_lengths: Option<&BTreeMap>, +) -> Result<(), ApiError> { let mut totals = Vec::with_capacity(releases.len()); for release in releases.iter() { let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone()) .map_err(|error| ApiError::Database(error.to_string()))?; let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX); let size = u64::try_from(release.size).unwrap_or(0); - totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total); + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); + totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); } let mut indices: Vec = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -1430,6 +1444,7 @@ mod tests { arr_core::policy::Candidate::PreGrab(&parsed), size_bytes, seeders, + 1, ) .total; #[allow(clippy::cast_precision_loss)] diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 73dc4ce..0c178b4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -1,7 +1,8 @@ +use std::collections::BTreeMap; use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::policy::language; use arr_db::{blacklist, Blacklist}; @@ -488,6 +489,7 @@ async fn movie_releases( &overrides, &original_language, &blacklist, + None, )?); } } @@ -511,7 +513,7 @@ async fn episode_releases( ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let episode = sqlx::query!( - r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, + r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id @@ -522,6 +524,7 @@ async fn episode_releases( .fetch_optional(database.pool()) .await? .ok_or(ApiError::EpisodeNotFound)?; + let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; let loaded = database .episode_policy(episode_id) .await @@ -564,6 +567,7 @@ async fn episode_releases( &loaded.overrides, &original_language, &blacklist, + Some(&season_lengths), )?); } } @@ -704,14 +708,22 @@ fn classify( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + season_lengths: Option<&BTreeMap>, ) -> Result { let parsed = arr_parse::parse(&release.name); + // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is + // divided by how many episodes it covers. `None` is the movie search — + // one release is one film. + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); let evaluation = evaluate( policy, overrides, original_language, Candidate::PreGrab(&parsed), release.size, + episodes, ); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) @@ -723,6 +735,7 @@ fn classify( Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episodes, ); // A release with no size has nothing to say about its size band, so that // term is dropped rather than scored as if it were at the floor. Every @@ -1451,6 +1464,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release") }; @@ -1544,7 +1558,7 @@ mod tests { imdb_id: None, }; let parsed = arr_parse::parse(&release.name); - let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8); + let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); let classified = classify( release, @@ -1552,6 +1566,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f7dd443..1fc7b6d 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -13,7 +13,7 @@ //! - **Status is derived, never stored** (§4.2). Every series the API returns //! carries a status computed from its episodes at request time. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; @@ -1360,7 +1360,7 @@ pub async fn episode_releases( State(state): State, Path(id): Path, ) -> Result>, ApiError> { - load_episode(&state, id).await?; + let episode = load_episode(&state, id).await?; let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; @@ -1372,7 +1372,8 @@ pub async fn episode_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::EpisodeNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, episode.series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } @@ -1407,6 +1408,34 @@ pub async fn grab_episode( /// Resolves a season by its number within one series, so the deck is /// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`). +/// Per-season episode counts for one series: the divisor data behind +/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no +/// revealed episodes counts zero, which `claimed_episode_count` treats as +/// unknown. +pub(crate) async fn season_lengths( + state: &AppState, + series_id: i64, +) -> Result, ApiError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = ? + GROUP BY se.number"#, + series_id + ) + .fetch_all(pool(state)?) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result { sqlx::query_scalar!( r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, @@ -1484,7 +1513,8 @@ pub async fn season_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::SeasonNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index cd3e2e0..f9ab8bb 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -64,6 +64,10 @@ pub struct EvaluationContext<'a> { /// when the caller has no size to offer, which abstains rather than /// rejects. pub size_bytes: Option, + /// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band + /// describes one episode, so the size rule divides by this. One for a + /// movie or an unknown count; zero is treated as one. + pub episode_count: u32, } /// A rule's identity when no violation exists to carry concrete evidence. @@ -107,6 +111,7 @@ pub fn evaluate( original_language: &Language, candidate: Candidate<'_>, size_bytes: Option, + episode_count: u32, ) -> Evaluation { let context = EvaluationContext { policy, @@ -114,6 +119,7 @@ pub fn evaluate( original_language, candidate, size_bytes, + episode_count, }; let rules: [&dyn PolicyRule; 6] = [ &ResolutionRule, @@ -227,7 +233,8 @@ impl PolicyRule for SizeRule { else { return RuleEvaluation::Unknown(RuleKind::Size); }; - match crate::score::is_below_floor(context.policy, resolution, size) { + match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) + { None => RuleEvaluation::Unknown(RuleKind::Size), Some(true) => RuleEvaluation::HardFail(Rule::Size), Some(false) => RuleEvaluation::Pass(RuleKind::Size), @@ -515,7 +522,7 @@ mod tests { } fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, &en(), candidate, None).verdict + evaluate(policy, overrides, &en(), candidate, None, 1).verdict } fn verdict_for( @@ -529,6 +536,7 @@ mod tests { original_language, candidate, None, + 1, ) .verdict } @@ -543,6 +551,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -725,6 +734,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -781,6 +791,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); let expected = if rejected { Verdict::Rejected(Rule::DolbyVisionProfile(profile)) @@ -800,6 +811,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -832,6 +844,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let soft = FixedRule { evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), @@ -860,6 +873,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let first = FixedRule { evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), @@ -995,7 +1009,8 @@ mod tests { &overrides, &en(), Candidate::PreGrab(&claims), - None + None, + 1 ) .verdict, Verdict::Eligible @@ -1008,7 +1023,8 @@ mod tests { &overrides, &en(), Candidate::PostDownload(&media), - None + None, + 1 ) .verdict, Verdict::Waived(Rule::RequiredAudio) @@ -1082,6 +1098,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); @@ -1093,6 +1110,7 @@ mod tests { &Language::PortugueseBrazil, Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); } @@ -1106,6 +1124,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index bc3bd92..3cfa5b4 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -19,6 +19,10 @@ //! remux playback test in `DESIGN.md` §14; the model is the decision, not the //! constants. +use std::collections::BTreeMap; + +use arr_parse::EpisodeClaim; + use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; const BYTES_PER_GIB: i64 = 1 << 30; @@ -84,13 +88,26 @@ pub struct Score { /// resolution the policy carries no band for — scores zero on the dominant /// term rather than being penalised for what the release name failed to say. /// The resolution-rank term treats an unclaimed resolution the same way. +/// +/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the +/// divisor that turns a season pack's total into the per-episode figure the +/// band is compared against. The caller supplies it — `arr-core` has no IO, +/// and a `Season` claim names a season, not a length. A movie, and any release +/// whose count is unknown, is one episode; zero is treated as one. #[must_use] -pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score { +pub fn score( + policy: &Policy, + candidate: Candidate<'_>, + size_bytes: u64, + seeders: u32, + episode_count: u32, +) -> Score { let weights = &policy.score_weights; let claimed = candidate.resolution(); + let per_episode = per_episode_size(size_bytes, episode_count); let size = claimed .and_then(|resolution| policy.size_bands.get(&resolution)) - .map_or(0, |band| size_points(band, weights, size_bytes)); + .map_or(0, |band| size_points(band, weights, per_episode)); let source = candidate .source() .map_or(0, |source| source_points(policy, source)); @@ -113,25 +130,69 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders /// /// Pre-grab, the name is all there is (`DESIGN.md` §5.6). #[must_use] -pub fn score_release(policy: &Policy, release: &Release) -> Score { +pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { score( policy, Candidate::PreGrab(&release.parsed), release.size, release.seeders, + episode_count, ) } /// Whether a size falls below its band's floor, which is a hard filter. /// +/// The floor takes the same per-episode figure as the target penalty +/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor +/// would let every pack through untested. Zero `episode_count` is one episode. +/// /// `None` when the policy carries no band for that resolution: no band is no /// opinion, not a rejection. #[must_use] -pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option { +pub fn is_below_floor( + policy: &Policy, + resolution: Resolution, + size_bytes: u64, + episode_count: u32, +) -> Option { policy .size_bands .get(&resolution) - .map(|band| size_bytes < band.floor_bytes) + .map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes) +} + +/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor +/// behind [`score`] and [`is_below_floor`]. +/// +/// `season_lengths` maps season number to that season's episode count as the +/// caller knows it — the caller supplies the fact the way `apply_auto_track` +/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes` +/// claim knows its own length; a `Season` or `Seasons` claim is summed from +/// the map. Anything unknown — no claim, a daily episode, a season the map +/// does not carry — falls back to one episode, which measures the release by +/// its full size and fails toward rejecting a good pack rather than grabbing +/// a bad one. +#[must_use] +pub fn claimed_episode_count( + claim: Option<&EpisodeClaim>, + season_lengths: &BTreeMap, +) -> u32 { + let count = match claim { + Some(EpisodeClaim::Episodes { episodes, .. }) => { + u32::try_from(episodes.len()).unwrap_or(u32::MAX) + } + Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0), + Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last) + .map(|season| season_lengths.get(&season).copied().unwrap_or(0)) + .fold(0u32, u32::saturating_add), + Some(EpisodeClaim::Daily { .. }) | None => 1, + }; + count.max(1) +} + +/// The per-episode figure every size band is compared against. +fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 { + size_bytes / u64::from(episode_count.max(1)) } /// The size term: a ramp from the floor up to the target, then a penalty that @@ -262,7 +323,13 @@ mod tests { fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score { let claims = claims(source); - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } /// The same, at a resolution the caller picks. @@ -277,10 +344,20 @@ mod tests { source: Some(source), ..NameClaims::default() }; - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } fn size_rule(size_bytes: u64) -> RuleEvaluation { + size_rule_for(size_bytes, 1) + } + + fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { let policy = policy(); let overrides = TitleOverrides::default(); let language = Language::Other("en".to_owned()); @@ -291,6 +368,7 @@ mod tests { original_language: &language, candidate: Candidate::PreGrab(&claims), size_bytes: Some(size_bytes), + episode_count, }) } @@ -321,6 +399,7 @@ mod tests { &Language::Other("en".to_owned()), Candidate::PreGrab(&claims), Some(gib(60)), + 1, ); // A bad score, but a score: nothing filters it out, so a selection @@ -387,10 +466,13 @@ mod tests { assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(3)), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), Some(true) ); - assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None); + assert_eq!( + is_below_floor(&policy(), Resolution::R720p, gib(3), 1), + None + ); } #[test] @@ -399,7 +481,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10); + let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); assert_eq!(score.size, 0); assert_eq!(score.total, score.source + score.seeders); @@ -417,8 +499,8 @@ mod tests { }, ); let claims = claims(ClaimedSource::WebDl); - let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10); - let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10); + let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); + let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(under.size, 0); @@ -466,7 +548,7 @@ mod tests { resolution: Some(resolution), ..NameClaims::default() }; - score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution + score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution }; assert_eq!(at(ClaimedResolution::P2160), 2 * step); @@ -483,7 +565,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10); + let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); assert_eq!(unranked.resolution, 0); assert_eq!(unclaimed.resolution, 0); @@ -500,4 +582,171 @@ mod tests { assert!(hd.seeders > uhd.seeders); assert!(uhd.total > hd.total); } + + /// Issue #180: a movie passes one episode's worth and must score exactly + /// as it did before packs learned to divide. Every term pinned to a + /// literal so the normalisation cannot quietly move movie scoring. + #[test] + fn a_movie_scores_exactly_as_before_pack_normalisation() { + let at_target = scored(ClaimedSource::WebDl, gib(22), 40); + assert_eq!( + at_target, + Score { + total: 1398, + size: 1000, + source: 50, + seeders: 48, + resolution: 300, + } + ); + + let over_target = scored(ClaimedSource::WebDl, gib(30), 40); + assert_eq!( + over_target, + Score { + total: 918, + size: 520, + source: 50, + seeders: 48, + resolution: 300, + } + ); + } + + #[test] + fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() { + let episode = scored(ClaimedSource::WebDl, gib(22), 10); + let claims = claims(ClaimedSource::WebDl); + let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10); + + assert_eq!(pack.size, episode.size); + assert_eq!(pack.total, episode.total); + } + + #[test] + fn a_pack_below_the_floor_per_episode_is_rejected() { + // 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor + // — a pack of mud-quality encodes fails as plainly as one of them. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(30), 10), + Some(true) + ); + assert_eq!( + size_rule_for(gib(30), 10), + RuleEvaluation::HardFail(Rule::Size) + ); + + // The same total over three episodes is 10 GiB each and passes. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), + Some(false) + ); + assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); + } + + #[test] + fn an_unknown_episode_count_falls_back_to_one_episode() { + let single = scored(ClaimedSource::WebDl, gib(22), 10); + let claims = claims(ClaimedSource::WebDl); + let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0); + + assert_eq!(zero, single); + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) + ); + } + + #[test] + fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() { + let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]); + + let episodes = EpisodeClaim::Episodes { + season: 1, + episodes: vec![2, 3, 4], + }; + assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths), + 8 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths), + 30 + ); + + // Unknowns are one episode: no claim, a daily episode, a season the + // map has not revealed yet. + assert_eq!(claimed_episode_count(None, &lengths), 1); + assert_eq!( + claimed_episode_count( + Some(&EpisodeClaim::Daily { + year: 2024, + month: 5, + day: 13 + }), + &lengths + ), + 1 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths), + 1 + ); + } + + /// The observed case from issue #180: Rick and Morty S08 against a + /// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB + /// over, resolution step 300). Measured by pack total, the 1080p pack's + /// smaller size penalty beat the 300-point resolution step; per episode, + /// 2160p ranks first as `resolution_pref` says it should. + #[test] + fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() { + let policy = Policy { + size_bands: BTreeMap::from([ + ( + Resolution::R1080p, + SizeBand { + floor_bytes: GIB / 4, + target_bytes: gib(2), + penalty_points_per_gib_over: 60, + }, + ), + ( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(1), + target_bytes: gib(6), + penalty_points_per_gib_over: 60, + }, + ), + ]), + ..self::policy() + }; + let episodes = 10; + let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB + let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB + + let scored = |resolution, size| { + let claims = NameClaims { + resolution: Some(resolution), + source: Some(ClaimedSource::WebDl), + ..NameClaims::default() + }; + score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) + }; + let hd = scored(ClaimedResolution::P1080, hd_pack); + let uhd = scored(ClaimedResolution::P2160, uhd_pack); + + // Neither pack trips the floor per episode, so the ranking decides. + assert_eq!( + is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes), + Some(false) + ); + assert_eq!( + is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), + Some(false) + ); + assert!(uhd.total > hd.total); + } } diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 66b0eb9..d462a77 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -13,13 +13,14 @@ //! - the `grabs` row is written from that response, so a crash between the add //! and the insert heals on the next tick instead of leaving an orphan. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use arr_core::policy::{evaluate, Candidate}; -use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict}; +use arr_core::score::{claimed_episode_count, score}; +use arr_core::{Language, Policy, TitleOverrides, Verdict}; use arr_db::{blacklist, Blacklist, Db, MoviePolicy}; use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest}; @@ -1198,6 +1199,7 @@ pub(crate) async fn store_release( overrides, original_language, blacklist, + 1, ) .await?; sqlx::query!( @@ -1224,6 +1226,21 @@ pub(crate) async fn store_episode_release( original_language: &Language, blacklist: &Blacklist, ) -> Result<(i64, Option), GrabError> { + // A size band describes one episode (`DESIGN.md` §5.5): the release is + // measured per episode, and a season pack's length comes from the series + // the covered episodes belong to. An unrevealed season divides by one. + let claim = arr_parse::parse(&release.name).episode; + let season_lengths = match episode_ids.first() { + Some(&episode_id) + if claim + .as_ref() + .is_some_and(arr_parse::EpisodeClaim::is_season_pack) => + { + season_lengths_of(database, episode_id).await? + } + _ => BTreeMap::new(), + }; + let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); let (release_id, eligible) = classify_and_store( database, release, @@ -1231,6 +1248,7 @@ pub(crate) async fn store_episode_release( overrides, original_language, blacklist, + episode_count, ) .await?; for episode_id in episode_ids { @@ -1246,6 +1264,35 @@ pub(crate) async fn store_episode_release( Ok((release_id, eligible)) } +/// Per-season episode counts for the series one covered episode belongs to +/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation. +async fn season_lengths_of( + database: &Db, + episode_id: i64, +) -> Result, GrabError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = (SELECT s2.series_id FROM episodes e2 + JOIN seasons s2 ON s2.id = e2.season_id + WHERE e2.id = ?) + GROUP BY se.number"#, + episode_id + ) + .fetch_all(database.pool()) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn classify_and_store( database: &Db, release: &SearchRelease, @@ -1253,6 +1300,7 @@ async fn classify_and_store( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + episode_count: u32, ) -> Result<(i64, Option), GrabError> { let parsed = arr_parse::parse(&release.name); let evaluation = evaluate( @@ -1261,12 +1309,14 @@ async fn classify_and_store( original_language, Candidate::PreGrab(&parsed), release.size, + episode_count, ); let scored = score( policy, Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episode_count, ); // A release that did not say its size is not a tiny one: scoring it // against the band's floor would bury it. Same treatment as the manual diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c55ef8d..c26dcd0 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -307,6 +307,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&feature.media), Some(feature.size), + 1, ); let waiver: Option = match evaluation.verdict { Verdict::Rejected(rule) => { @@ -494,6 +495,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&assignment.file.media), Some(assignment.file.size), + 1, ); let waiver = match evaluation.verdict { Verdict::Rejected(rule) => { diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 5130f33..354d183 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -619,7 +619,12 @@ mod tests { let one_and_a_half_gib = 1536 << 20; assert_eq!( - arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib), + arr_core::score::is_below_floor( + &loaded.policy, + Resolution::R1080p, + one_and_a_half_gib, + 1 + ), Some(false) ); }