Merge #180: score season packs per episode

Closes #180
This commit is contained in:
Miguel Palhas
2026-08-24 21:27:42 +01:00
11 changed files with 502 additions and 39 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
+19 -4
View File
@@ -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<u32, u32>>,
) -> 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<usize> = (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)]
+18 -3
View File
@@ -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<Vec<ClassifiedRelease>, 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<u32, u32>>,
) -> Result<ClassifiedRelease, ApiError> {
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");
+34 -4
View File
@@ -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<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, 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<BTreeMap<u32, u32>, 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<i64, ApiError> {
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))
}
+23 -4
View File
@@ -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<u64>,
/// 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<u64>,
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!(
+263 -14
View File
@@ -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<bool> {
pub fn is_below_floor(
policy: &Policy,
resolution: Resolution,
size_bytes: u64,
episode_count: u32,
) -> Option<bool> {
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, u32>,
) -> 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);
}
}
+52 -2
View File
@@ -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<Eligible>), 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<BTreeMap<u32, u32>, 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<Eligible>), 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
+2
View File
@@ -307,6 +307,7 @@ impl ImportAction {
&original_language,
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
);
let waiver: Option<Rule> = 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) => {
+6 -1
View File
@@ -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)
);
}