feat: scale size bands by episode runtime

Implements #209 per §5.5 as amended by #208: a band's floor and target
are rates against a 45-minute reference runtime, scaled by the series'
minutes per episode. A missing or zero runtime applies the bands
unscaled, and movies are never scaled. The runtime is stored on the
series row (new migration), filled on add and by the metadata refresh,
which never blanks a known value against TMDB's frequently-empty
episode_run_time. Composes with #210: allow_below_floor waives against
the scaled floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 22:45:55 +01:00
parent 024786f356
commit 917aa4fa76
20 changed files with 573 additions and 50 deletions
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ", "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN seasons se ON se.id = g.target_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'season'\n ORDER BY g.id\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -103,8 +103,19 @@
} }
}, },
{ {
"name": "release_name!: String", "name": "runtime_minutes",
"ordinal": 9, "ordinal": 9,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 10,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
@@ -127,8 +138,9 @@
false, false,
true, true,
true, true,
true,
false false
] ]
}, },
"hash": "7963cce11a588f8b8697e596da24802e4a5556c388b84fe7a6102444a887c2cc" "hash": "1fa97a49b40502e95b7618ac8fed38ad03e58f95ec9636a79ef0c06b24635a67"
} }
@@ -1,12 +1,12 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Right": 13 "Right": 14
}, },
"nullable": [] "nullable": []
}, },
"hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331" "hash": "3cc5524f3ac253e86de8618970a5413135a87ccbd8ec3f73e21ef9c73e8ba80c"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series WHERE id = ?", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series WHERE id = ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -145,6 +145,17 @@
"name": "vote_average" "name": "vote_average"
} }
} }
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -163,8 +174,9 @@
true, true,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10" "hash": "4af1ce70996a3dc496b0ff507b8c70a43cde5f8b79ea02483047626fc11bc377"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ", "query": "\n SELECT g.id AS \"grab_id!: i64\",\n g.infohash AS \"infohash!: String\",\n e.id AS \"episode_id!: i64\",\n se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.id AS \"series_id!: i64\",\n s.tmdb_id AS \"series_tmdb_id!: i64\",\n s.title AS \"series_title!: String\",\n s.year AS \"series_year\",\n s.original_language,\n s.runtime_minutes,\n r.name AS \"release_name!: String\"\n FROM grabs g\n JOIN episodes e ON e.id = g.target_id\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n JOIN releases r ON r.id = g.release_id\n WHERE g.state = 'downloaded' AND g.target_kind = 'episode'\n ORDER BY g.id\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -114,8 +114,19 @@
} }
}, },
{ {
"name": "release_name!: String", "name": "runtime_minutes",
"ordinal": 10, "ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
},
{
"name": "release_name!: String",
"ordinal": 11,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
@@ -139,8 +150,9 @@
false, false,
true, true,
true, true,
true,
false false
] ]
}, },
"hash": "7f94d0bad8dd346c5e930c2606c8643e732850b0a1ec331e216c3aedf6987ad4" "hash": "81361374c5c84d4c12ac33fc103ab96ce79164dc551f388180c554034dec007b"
} }
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT runtime_minutes FROM series WHERE id = ?",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b23e7c444df0fa99f891a59f834ea7777979f43b63d4e7c00fa8358431e6b3d9"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average, runtime_minutes\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -145,6 +145,17 @@
"name": "vote_average" "name": "vote_average"
} }
} }
},
{
"name": "runtime_minutes",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
} }
], ],
"parameters": { "parameters": {
@@ -163,8 +174,9 @@
true, true,
true, true,
true, true,
true,
true true
] ]
}, },
"hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644" "hash": "ee34b26d3145587c48d8777542082aeed6f3feaf13492244fdc9e0eab2af579b"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "f6b954dcaaeb797161952cd4697037693173cf966e2d91f8f7f84d7f0e579b16"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT s.runtime_minutes FROM series s\n WHERE s.id = (SELECT s2.series_id FROM episodes e\n JOIN seasons s2 ON s2.id = e.season_id\n WHERE e.id = ?)",
"describe": {
"columns": [
{
"name": "runtime_minutes",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "series",
"name": "runtime_minutes"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "fffd6c2609b3389aa94d41cdde6e0e9113ce6c60498772b1fc056ffa037d1166"
}
+18 -2
View File
@@ -610,7 +610,7 @@ pub async fn releases(
.map_err(|error| ApiError::Database(error.to_string()))? .map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::NotFound)? .ok_or(ApiError::NotFound)?
.policy; .policy;
rescore(&mut releases, &policy, None)?; rescore(&mut releases, &policy, None, 0)?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -624,6 +624,10 @@ pub async fn releases(
/// many it covers. `None` is the movie decks: one release is one film, /// many it covers. `None` is the movie decks: one release is one film,
/// whatever episode-shaped noise its name parses to. /// whatever episode-shaped noise its name parses to.
/// ///
/// `runtime_minutes` scales the bands by the series' minutes per episode
/// (§5.5). Movies are never scaled, so the movie decks pass zero — the same
/// zero a series with no known runtime gets.
///
/// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// 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 /// policy weights in the thousands), so the `i64` -> `f64` cast into the
/// column's storage type is exact. /// column's storage type is exact.
@@ -632,6 +636,7 @@ pub(crate) fn rescore(
releases: &mut [Release], releases: &mut [Release],
policy: &Policy, policy: &Policy,
season_lengths: Option<&BTreeMap<u32, u32>>, season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let mut totals = Vec::with_capacity(releases.len()); let mut totals = Vec::with_capacity(releases.len());
for release in releases.iter() { for release in releases.iter() {
@@ -642,7 +647,17 @@ pub(crate) fn rescore(
let episodes = season_lengths.map_or(1, |lengths| { let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths) claimed_episode_count(parsed.episode.as_ref(), lengths)
}); });
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); totals.push(
score(
policy,
Candidate::PreGrab(&parsed),
size,
seeders,
episodes,
runtime_minutes,
)
.total,
);
} }
let mut indices: Vec<usize> = (0..releases.len()).collect(); let mut indices: Vec<usize> = (0..releases.len()).collect();
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
@@ -1559,6 +1574,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
.total; .total;
#[allow(clippy::cast_precision_loss)] #[allow(clippy::cast_precision_loss)]
+6
View File
@@ -77,6 +77,7 @@ pub(crate) async fn movie(state: &AppState, movie_id: i64) -> Result<(), ApiErro
&loaded.overrides, &loaded.overrides,
&language, &language,
None, None,
0,
) )
.await .await
} }
@@ -127,6 +128,7 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
// A size band describes one episode (§5.5), so a pack's verdict needs the // A size band describes one episode (§5.5), so a pack's verdict needs the
// same divisor the deck scores it with. // same divisor the deck scores it with.
let lengths = crate::series::season_lengths(state, series_id).await?; let lengths = crate::series::season_lengths(state, series_id).await?;
let runtime = crate::series::series_runtime(state, series_id).await?;
apply( apply(
state, state,
&releases, &releases,
@@ -134,10 +136,12 @@ pub(crate) async fn series(state: &AppState, series_id: i64) -> Result<(), ApiEr
&loaded.overrides, &loaded.overrides,
&language, &language,
Some(&lengths), Some(&lengths),
runtime,
) )
.await .await
} }
#[allow(clippy::too_many_arguments)]
async fn apply( async fn apply(
state: &AppState, state: &AppState,
releases: &[Stored], releases: &[Stored],
@@ -145,6 +149,7 @@ async fn apply(
overrides: &TitleOverrides, overrides: &TitleOverrides,
original_language: &Language, original_language: &Language,
season_lengths: Option<&BTreeMap<u32, u32>>, season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
for release in releases { for release in releases {
if release.rejected_rule.as_deref() == Some(blacklist::RULE) { if release.rejected_rule.as_deref() == Some(blacklist::RULE) {
@@ -166,6 +171,7 @@ async fn apply(
Candidate::PreGrab(&parsed), Candidate::PreGrab(&parsed),
size, size,
episodes, episodes,
runtime_minutes,
); );
// `releases` allows a rule name only on a rejected row // `releases` allows a rule name only on a rejected row
// (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`), // (`CHECK ((verdict = 'rejected') = (rejected_rule IS NOT NULL))`),
+9 -1
View File
@@ -490,6 +490,7 @@ async fn movie_releases(
&original_language, &original_language,
&blacklist, &blacklist,
None, None,
0,
)?); )?);
} }
} }
@@ -525,6 +526,7 @@ async fn episode_releases(
.await? .await?
.ok_or(ApiError::EpisodeNotFound)?; .ok_or(ApiError::EpisodeNotFound)?;
let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; let season_lengths = crate::series::season_lengths(state, episode.series_id).await?;
let runtime_minutes = crate::series::series_runtime(state, episode.series_id).await?;
let loaded = database let loaded = database
.episode_policy(episode_id) .episode_policy(episode_id)
.await .await
@@ -568,6 +570,7 @@ async fn episode_releases(
&original_language, &original_language,
&blacklist, &blacklist,
Some(&season_lengths), Some(&season_lengths),
runtime_minutes,
)?); )?);
} }
} }
@@ -709,6 +712,7 @@ fn classify(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
season_lengths: Option<&BTreeMap<u32, u32>>, season_lengths: Option<&BTreeMap<u32, u32>>,
runtime_minutes: u32,
) -> Result<ClassifiedRelease, ApiError> { ) -> Result<ClassifiedRelease, ApiError> {
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
// A size band describes one episode (`DESIGN.md` §5.5): a pack's size is // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is
@@ -724,6 +728,7 @@ fn classify(
Candidate::PreGrab(&parsed), Candidate::PreGrab(&parsed),
release.size, release.size,
episodes, episodes,
runtime_minutes,
); );
let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) {
("rejected", Some(blacklist::RULE.to_owned())) ("rejected", Some(blacklist::RULE.to_owned()))
@@ -736,6 +741,7 @@ fn classify(
release.size.unwrap_or_default(), release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(), release.seeders.unwrap_or_default(),
episodes, episodes,
runtime_minutes,
); );
// A release with no size has nothing to say about its size band, so that // 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 // term is dropped rather than scored as if it were at the floor. Every
@@ -1465,6 +1471,7 @@ mod tests {
&Language::Other("en".into()), &Language::Other("en".into()),
&Blacklist::default(), &Blacklist::default(),
None, None,
0,
) )
.expect("classified release") .expect("classified release")
}; };
@@ -1558,7 +1565,7 @@ mod tests {
imdb_id: None, imdb_id: None,
}; };
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1, 0);
let classified = classify( let classified = classify(
release, release,
@@ -1567,6 +1574,7 @@ mod tests {
&Language::Other("en".into()), &Language::Other("en".into()),
&Blacklist::default(), &Blacklist::default(),
None, None,
0,
) )
.expect("classified release"); .expect("classified release");
+23 -4
View File
@@ -468,11 +468,15 @@ pub async fn create(
let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone()); let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone());
let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone()); let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone());
let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average); let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average);
let runtime_minutes = tmdb_series
.as_ref()
.and_then(|s| s.episode_runtime)
.map(i64::from);
let result = sqlx::query!( let result = sqlx::query!(
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average, runtime_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides, input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average, poster_path, backdrop_path, vote_average, runtime_minutes,
) )
.execute(pool(&state)?) .execute(pool(&state)?)
.await?; .await?;
@@ -1379,7 +1383,8 @@ pub async fn episode_releases(
.ok_or(ApiError::EpisodeNotFound)? .ok_or(ApiError::EpisodeNotFound)?
.policy; .policy;
let lengths = season_lengths(&state, episode.series_id).await?; let lengths = season_lengths(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?; let runtime = series_runtime(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases)) Ok(Json(releases))
} }
@@ -1418,6 +1423,19 @@ pub async fn grab_episode(
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no /// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
/// revealed episodes counts zero, which `claimed_episode_count` treats as /// revealed episodes counts zero, which `claimed_episode_count` treats as
/// unknown. /// unknown.
/// The series' minutes per episode (`DESIGN.md` §5.5): the scale factor
/// behind runtime-scaled size bands. Zero when unknown, which applies the
/// bands unscaled.
pub(crate) async fn series_runtime(state: &AppState, series_id: i64) -> Result<u32, ApiError> {
let minutes = sqlx::query_scalar!("SELECT runtime_minutes FROM series WHERE id = ?", series_id)
.fetch_optional(pool(state)?)
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
pub(crate) async fn season_lengths( pub(crate) async fn season_lengths(
state: &AppState, state: &AppState,
series_id: i64, series_id: i64,
@@ -1520,7 +1538,8 @@ pub async fn season_releases(
.ok_or(ApiError::SeasonNotFound)? .ok_or(ApiError::SeasonNotFound)?
.policy; .policy;
let lengths = season_lengths(&state, series_id).await?; let lengths = season_lengths(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?; let runtime = series_runtime(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths), runtime)?;
Ok(Json(releases)) Ok(Json(releases))
} }
+70 -5
View File
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
/// describes one episode, so the size rule divides by this. One for a /// describes one episode, so the size rule divides by this. One for a
/// movie or an unknown count; zero is treated as one. /// movie or an unknown count; zero is treated as one.
pub episode_count: u32, pub episode_count: u32,
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
/// rate against 45 minutes, so the size rule scales its floor by
/// `runtime / 45`. Zero is a missing runtime and applies the band
/// unscaled; movies are never scaled and pass zero.
pub runtime_minutes: u32,
} }
/// A rule's identity when no violation exists to carry concrete evidence. /// A rule's identity when no violation exists to carry concrete evidence.
@@ -112,6 +117,7 @@ pub fn evaluate(
candidate: Candidate<'_>, candidate: Candidate<'_>,
size_bytes: Option<u64>, size_bytes: Option<u64>,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Evaluation { ) -> Evaluation {
let context = EvaluationContext { let context = EvaluationContext {
policy, policy,
@@ -120,6 +126,7 @@ pub fn evaluate(
candidate, candidate,
size_bytes, size_bytes,
episode_count, episode_count,
runtime_minutes,
}; };
let rules: [&dyn PolicyRule; 6] = [ let rules: [&dyn PolicyRule; 6] = [
&ResolutionRule, &ResolutionRule,
@@ -239,8 +246,13 @@ impl PolicyRule for SizeRule {
else { else {
return RuleEvaluation::Unknown(RuleKind::Size); return RuleEvaluation::Unknown(RuleKind::Size);
}; };
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) match crate::score::is_below_floor(
{ context.policy,
resolution,
size,
context.episode_count,
context.runtime_minutes,
) {
None => RuleEvaluation::Unknown(RuleKind::Size), None => RuleEvaluation::Unknown(RuleKind::Size),
Some(true) if context.overrides.allow_below_floor => { Some(true) if context.overrides.allow_below_floor => {
RuleEvaluation::SoftFail(Rule::Size) RuleEvaluation::SoftFail(Rule::Size)
@@ -531,7 +543,7 @@ mod tests {
} }
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
evaluate(policy, overrides, &en(), candidate, None, 1).verdict evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
} }
fn verdict_for( fn verdict_for(
@@ -546,6 +558,7 @@ mod tests {
candidate, candidate,
None, None,
1, 1,
0,
) )
.verdict .verdict
} }
@@ -561,6 +574,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -690,6 +704,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
Some(1 << 30), Some(1 << 30),
1, 1,
0,
); );
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size)); assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
@@ -714,6 +729,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
Some(1 << 30), Some(1 << 30),
1, 1,
0,
) )
.verdict, .verdict,
Verdict::Waived(Rule::Size) Verdict::Waived(Rule::Size)
@@ -729,6 +745,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
Some(1 << 30), Some(1 << 30),
1, 1,
0,
) )
.verdict, .verdict,
Verdict::Waived(Rule::Size) Verdict::Waived(Rule::Size)
@@ -751,6 +768,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
Some(4 << 30), Some(4 << 30),
1, 1,
0,
) )
.verdict, .verdict,
Verdict::Eligible Verdict::Eligible
@@ -775,6 +793,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
size, size,
10, 10,
0,
) )
.verdict, .verdict,
Verdict::Rejected(Rule::Size) Verdict::Rejected(Rule::Size)
@@ -790,12 +809,48 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
size, size,
10, 10,
0,
) )
.verdict, .verdict,
Verdict::Waived(Rule::Size) Verdict::Waived(Rule::Size)
); );
} }
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
/// floor, and `allow_below_floor` still only softens what remains below
/// it — it never bypasses the scaled comparison.
#[test]
fn allow_below_floor_waives_against_the_scaled_floor() {
let policy = banded_policy();
let waive = TitleOverrides {
allow_below_floor: true,
..TitleOverrides::default()
};
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
// the override has nothing to waive; 0.5 GiB is below even the
// scaled floor and stays a waiver rather than eligible.
let at = |size_bytes, overrides| {
evaluate(
&policy,
overrides,
&en(),
Candidate::PreGrab(&claims),
Some(size_bytes),
1,
22,
)
.verdict
};
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
assert_eq!(
at(1 << 29, &TitleOverrides::default()),
Verdict::Rejected(Rule::Size)
);
}
#[test] #[test]
fn every_unsafe_source_hard_fails_in_both_phases() { fn every_unsafe_source_hard_fails_in_both_phases() {
let policy = policy(); let policy = policy();
@@ -876,6 +931,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -933,6 +989,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
let expected = if rejected { let expected = if rejected {
Verdict::Rejected(Rule::DolbyVisionProfile(profile)) Verdict::Rejected(Rule::DolbyVisionProfile(profile))
@@ -953,6 +1010,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
@@ -986,6 +1044,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: None, size_bytes: None,
episode_count: 1, episode_count: 1,
runtime_minutes: 0,
}; };
let soft = FixedRule { let soft = FixedRule {
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
@@ -1015,6 +1074,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: None, size_bytes: None,
episode_count: 1, episode_count: 1,
runtime_minutes: 0,
}; };
let first = FixedRule { let first = FixedRule {
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
@@ -1151,7 +1211,8 @@ mod tests {
&en(), &en(),
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1 1,
0,
) )
.verdict, .verdict,
Verdict::Eligible Verdict::Eligible
@@ -1165,7 +1226,8 @@ mod tests {
&en(), &en(),
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1 1,
0,
) )
.verdict, .verdict,
Verdict::Waived(Rule::RequiredAudio) Verdict::Waived(Rule::RequiredAudio)
@@ -1240,6 +1302,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
@@ -1252,6 +1315,7 @@ mod tests {
Candidate::PostDownload(&media), Candidate::PostDownload(&media),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
} }
@@ -1266,6 +1330,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
None, None,
1, 1,
0,
); );
assert_eq!(report.verdict, Verdict::Eligible); assert_eq!(report.verdict, Verdict::Eligible);
assert_eq!( assert_eq!(
+193 -22
View File
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
const BYTES_PER_GIB: i64 = 1 << 30; const BYTES_PER_GIB: i64 = 1 << 30;
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
/// rate against a 45-minute episode, and both floor and target scale by
/// `runtime / 45` before a per-episode size is compared to them.
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
/// How much each scoring term is worth. Policy data, not constants in the /// How much each scoring term is worth. Policy data, not constants in the
/// code, for the same reason the size bands are. /// code, for the same reason the size bands are.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -94,6 +99,13 @@ pub struct Score {
/// band is compared against. The caller supplies it — `arr-core` has no IO, /// 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 /// 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. /// whose count is unknown, is one episode; zero is treated as one.
///
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
/// same way — scales its floor and target before the comparison. Zero is a
/// missing runtime and applies the band unscaled, exactly the pre-scaling
/// behaviour. Movies are never scaled: their bands are already tuned against
/// feature length, so a movie caller passes zero.
#[must_use] #[must_use]
pub fn score( pub fn score(
policy: &Policy, policy: &Policy,
@@ -101,13 +113,16 @@ pub fn score(
size_bytes: u64, size_bytes: u64,
seeders: u32, seeders: u32,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Score { ) -> Score {
let weights = &policy.score_weights; let weights = &policy.score_weights;
let claimed = candidate.resolution(); let claimed = candidate.resolution();
let per_episode = per_episode_size(size_bytes, episode_count); let per_episode = per_episode_size(size_bytes, episode_count);
let size = claimed let size = claimed
.and_then(|resolution| policy.size_bands.get(&resolution)) .and_then(|resolution| policy.size_bands.get(&resolution))
.map_or(0, |band| size_points(band, weights, per_episode)); .map_or(0, |band| {
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
});
let source = candidate let source = candidate
.source() .source()
.map_or(0, |source| source_points(policy, source)); .map_or(0, |source| source_points(policy, source));
@@ -130,13 +145,19 @@ pub fn score(
/// ///
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6). /// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
#[must_use] #[must_use]
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { pub fn score_release(
policy: &Policy,
release: &Release,
episode_count: u32,
runtime_minutes: u32,
) -> Score {
score( score(
policy, policy,
Candidate::PreGrab(&release.parsed), Candidate::PreGrab(&release.parsed),
release.size, release.size,
release.seeders, release.seeders,
episode_count, episode_count,
runtime_minutes,
) )
} }
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor /// (`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. /// would let every pack through untested. Zero `episode_count` is one episode.
/// ///
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
///
/// `None` when the policy carries no band for that resolution: no band is no /// `None` when the policy carries no band for that resolution: no band is no
/// opinion, not a rejection. /// opinion, not a rejection.
#[must_use] #[must_use]
@@ -154,11 +178,11 @@ pub fn is_below_floor(
resolution: Resolution, resolution: Resolution,
size_bytes: u64, size_bytes: u64,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Option<bool> { ) -> Option<bool> {
policy policy.size_bands.get(&resolution).map(|band| {
.size_bands per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
.get(&resolution) })
.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 /// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
@@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
size_bytes / u64::from(episode_count.max(1)) size_bytes / u64::from(episode_count.max(1))
} }
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
/// the reference runtime — the band applies unscaled.
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
return *band;
}
SizeBand {
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
}
}
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
let scaled =
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// The size term: a ramp from the floor up to the target, then a penalty that /// The size term: a ramp from the floor up to the target, then a penalty that
/// grows with every gigabyte above it. /// grows with every gigabyte above it.
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 { fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
@@ -329,6 +373,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
} }
@@ -350,6 +395,7 @@ mod tests {
size_bytes, size_bytes,
seeders, seeders,
1, 1,
0,
) )
} }
@@ -358,6 +404,10 @@ mod tests {
} }
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
size_rule_at(size_bytes, episode_count, 0)
}
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
let policy = policy(); let policy = policy();
let overrides = TitleOverrides::default(); let overrides = TitleOverrides::default();
let language = Language::Other("en".to_owned()); let language = Language::Other("en".to_owned());
@@ -369,6 +419,7 @@ mod tests {
candidate: Candidate::PreGrab(&claims), candidate: Candidate::PreGrab(&claims),
size_bytes: Some(size_bytes), size_bytes: Some(size_bytes),
episode_count, episode_count,
runtime_minutes,
}) })
} }
@@ -400,6 +451,7 @@ mod tests {
Candidate::PreGrab(&claims), Candidate::PreGrab(&claims),
Some(gib(60)), Some(gib(60)),
1, 1,
0,
); );
// A bad score, but a score: nothing filters it out, so a selection // A bad score, but a score: nothing filters it out, so a selection
@@ -466,11 +518,11 @@ mod tests {
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
Some(true) Some(true)
); );
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R720p, gib(3), 1), is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
None None
); );
} }
@@ -481,7 +533,7 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
assert_eq!(score.size, 0); assert_eq!(score.size, 0);
assert_eq!(score.total, score.source + score.seeders); assert_eq!(score.total, score.source + score.seeders);
@@ -499,8 +551,8 @@ mod tests {
}, },
); );
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
assert_eq!(under.size, 0); assert_eq!(under.size, 0);
@@ -548,7 +600,7 @@ mod tests {
resolution: Some(resolution), resolution: Some(resolution),
..NameClaims::default() ..NameClaims::default()
}; };
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
}; };
assert_eq!(at(ClaimedResolution::P2160), 2 * step); assert_eq!(at(ClaimedResolution::P2160), 2 * step);
@@ -565,7 +617,7 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
assert_eq!(unranked.resolution, 0); assert_eq!(unranked.resolution, 0);
assert_eq!(unclaimed.resolution, 0); assert_eq!(unclaimed.resolution, 0);
@@ -617,7 +669,7 @@ mod tests {
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() { 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 episode = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10); let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
assert_eq!(pack.size, episode.size); assert_eq!(pack.size, episode.size);
assert_eq!(pack.total, episode.total); assert_eq!(pack.total, episode.total);
@@ -628,7 +680,7 @@ mod tests {
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor // 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. // — a pack of mud-quality encodes fails as plainly as one of them.
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10), is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
Some(true) Some(true)
); );
assert_eq!( assert_eq!(
@@ -638,22 +690,141 @@ mod tests {
// The same total over three episodes is 10 GiB each and passes. // The same total over three episodes is 10 GiB each and passes.
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
Some(false) Some(false)
); );
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
} }
/// The corrected acceptance criterion from issue #209: a series whose
/// runtime is known and short is judged against a proportionally scaled
/// floor and target, at both 22 and 45 minutes.
#[test]
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
// 5 GiB fails the unscaled floor and clears the 22-minute one.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
Some(true)
);
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
Some(false)
);
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
Some(true)
);
// 45 minutes is the reference runtime: identical to no scaling.
assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
);
// The size rule takes the same scaled floor.
assert_eq!(
size_rule_at(gib(5), 1, 0),
RuleEvaluation::HardFail(Rule::Size)
);
assert_eq!(
size_rule_at(gib(5), 1, 45),
RuleEvaluation::HardFail(Rule::Size)
);
assert!(matches!(
size_rule_at(gib(5), 1, 22),
RuleEvaluation::Pass(_)
));
assert_eq!(
size_rule_at(gib(3), 1, 22),
RuleEvaluation::HardFail(Rule::Size)
);
}
#[test]
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
let claims = claims(ClaimedSource::WebDl);
let scored_at_runtime = |size, runtime| {
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
};
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
// minutes, and both sit at the top of the size term.
let at_target = i64::from(ScoreWeights::default().size_at_target);
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
// A below-target bitrate lands on the same point of the ramp at any
// runtime, give or take integer rounding.
let half_way_45 = scored_at_runtime(gib(15), 45);
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
assert!((half_way_45 - half_way_22).abs() <= 1);
}
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
/// scaling must not let them through.
#[test]
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
let mut policy = policy();
policy.size_bands.insert(
Resolution::R1080p,
SizeBand {
floor_bytes: gib(1),
target_bytes: gib(2),
penalty_points_per_gib_over: 60,
},
);
let episodes = 10;
for per_episode_gib in [19, 24, 32] {
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
Some(true)
);
}
// Half a GiB per episode clears the scaled floor: the floor still
// discriminates rather than rejecting every 22-minute release.
assert_eq!(
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
Some(false)
);
}
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
/// reproduces the pre-scaling score exactly, pinned to literals the same
/// way #180 pinned movie scoring.
#[test]
fn a_missing_runtime_reproduces_the_unscaled_score() {
let claims = claims(ClaimedSource::WebDl);
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
assert_eq!(
missing,
Score {
total: 918,
size: 520,
source: 50,
seeders: 48,
resolution: 300,
}
);
assert_eq!(
missing,
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
);
}
#[test] #[test]
fn an_unknown_episode_count_falls_back_to_one_episode() { fn an_unknown_episode_count_falls_back_to_one_episode() {
let single = scored(ClaimedSource::WebDl, gib(22), 10); let single = scored(ClaimedSource::WebDl, gib(22), 10);
let claims = claims(ClaimedSource::WebDl); let claims = claims(ClaimedSource::WebDl);
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0); let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 0);
assert_eq!(zero, single); assert_eq!(zero, single);
assert_eq!( assert_eq!(
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
); );
} }
@@ -733,18 +904,18 @@ mod tests {
source: Some(ClaimedSource::WebDl), source: Some(ClaimedSource::WebDl),
..NameClaims::default() ..NameClaims::default()
}; };
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
}; };
let hd = scored(ClaimedResolution::P1080, hd_pack); let hd = scored(ClaimedResolution::P1080, hd_pack);
let uhd = scored(ClaimedResolution::P2160, uhd_pack); let uhd = scored(ClaimedResolution::P2160, uhd_pack);
// Neither pack trips the floor per episode, so the ranking decides. // Neither pack trips the floor per episode, so the ranking decides.
assert_eq!( assert_eq!(
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes), is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
Some(false) Some(false)
); );
assert_eq!( assert_eq!(
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
Some(false) Some(false)
); );
assert!(uhd.total > hd.total); assert!(uhd.total > hd.total);
+31
View File
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
) -> Result<Option<Eligible>, GrabError> { ) -> Result<Option<Eligible>, GrabError> {
// A movie is one episode's worth and is never runtime-scaled (§5.5).
let (release_id, eligible) = classify_and_store( let (release_id, eligible) = classify_and_store(
database, database,
release, release,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language, original_language,
blacklist, blacklist,
1, 1,
0,
) )
.await?; .await?;
sqlx::query!( sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => BTreeMap::new(), _ => BTreeMap::new(),
}; };
let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths);
// §5.5: the size bands scale by the series' minutes per episode.
let runtime_minutes = match episode_ids.first() {
Some(&episode_id) => series_runtime_of(database, episode_id).await?,
None => 0,
};
let (release_id, eligible) = classify_and_store( let (release_id, eligible) = classify_and_store(
database, database,
release, release,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language, original_language,
blacklist, blacklist,
episode_count, episode_count,
runtime_minutes,
) )
.await?; .await?;
for episode_id in episode_ids { for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.collect()) .collect())
} }
/// The minutes-per-episode of the series one covered episode belongs to
/// (`DESIGN.md` §5.5): the scale factor for its size bands. Zero when the
/// series has no known runtime, which applies the bands unscaled.
async fn series_runtime_of(database: &Db, episode_id: i64) -> Result<u32, GrabError> {
let minutes = sqlx::query_scalar!(
r#"SELECT s.runtime_minutes FROM series s
WHERE s.id = (SELECT s2.series_id FROM episodes e
JOIN seasons s2 ON s2.id = e.season_id
WHERE e.id = ?)"#,
episode_id
)
.fetch_optional(database.pool())
.await?
.flatten();
Ok(minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0))
}
#[allow(clippy::too_many_arguments)]
async fn classify_and_store( async fn classify_and_store(
database: &Db, database: &Db,
release: &SearchRelease, release: &SearchRelease,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
episode_count: u32, episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> { ) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name); let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate( let evaluation = evaluate(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed), Candidate::PreGrab(&parsed),
release.size, release.size,
episode_count, episode_count,
runtime_minutes,
); );
let scored = score( let scored = score(
policy, policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
release.size.unwrap_or_default(), release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(), release.seeders.unwrap_or_default(),
episode_count, episode_count,
runtime_minutes,
); );
// A release that did not say its size is not a tiny one: scoring it // 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 // against the band's floor would bury it. Same treatment as the manual
+14
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media), Candidate::PostDownload(&feature.media),
Some(feature.size), Some(feature.size),
1, 1,
0,
); );
let waiver: Option<Rule> = match evaluation.verdict { let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => { Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be // §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns // imported, before anything is placed: one hard failure condemns
// the whole release (§5.7), not the episodes. // the whole release (§5.7), not the episodes.
let runtime_minutes = pending
.runtime_minutes
.and_then(|minutes| u32::try_from(minutes).ok())
.unwrap_or(0);
let mut imports = Vec::new(); let mut imports = Vec::new();
for assignment in assignments { for assignment in assignments {
if assignment.episode.has_file { if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media), Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size), Some(assignment.file.size),
1, 1,
runtime_minutes,
); );
let waiver = match evaluation.verdict { let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => { Verdict::Rejected(rule) => {
@@ -938,6 +944,10 @@ struct PendingTvImport {
series_title: String, series_title: String,
series_year: Option<i64>, series_year: Option<i64>,
original_language: Option<String>, original_language: Option<String>,
/// §5.5: the series' minutes per episode, scaling the size bands the
/// same way the pre-grab verdict scaled them. `None` applies them
/// unscaled.
runtime_minutes: Option<i64>,
release_name: String, release_name: String,
} }
@@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String", s.title AS "series_title!: String",
s.year AS "series_year", s.year AS "series_year",
s.original_language, s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String" r.name AS "release_name!: String"
FROM grabs g FROM grabs g
JOIN episodes e ON e.id = g.target_id JOIN episodes e ON e.id = g.target_id
@@ -997,6 +1008,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title, series_title: row.series_title,
series_year: row.series_year, series_year: row.series_year,
original_language: row.original_language, original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name, release_name: row.release_name,
})); }));
@@ -1011,6 +1023,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String", s.title AS "series_title!: String",
s.year AS "series_year", s.year AS "series_year",
s.original_language, s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String" r.name AS "release_name!: String"
FROM grabs g FROM grabs g
JOIN seasons se ON se.id = g.target_id JOIN seasons se ON se.id = g.target_id
@@ -1033,6 +1046,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
series_title: row.series_title, series_title: row.series_title,
series_year: row.series_year, series_year: row.series_year,
original_language: row.original_language, original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name, release_name: row.release_name,
})); }));
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language, title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average poster_path, backdrop_path, vote_average, runtime_minutes
FROM series FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
) )
@@ -118,7 +118,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language, title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at, upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average poster_path, backdrop_path, vote_average, runtime_minutes
FROM series WHERE id = ?"#, FROM series WHERE id = ?"#,
series_id series_id
) )
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
changed = true; changed = true;
} }
} }
// §5.5: the size bands scale by this. TMDB's `episode_run_time` is
// frequently empty; a known value is never overwritten by a missing
// one, so a series keeps its runtime across TMDB's blank spells.
let runtime = metadata.episode_runtime.map(i64::from);
if runtime.is_some() && runtime != stale.runtime_minutes {
sqlx::query!(
"UPDATE series SET runtime_minutes = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
runtime,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
let ended = is_upstream_ended(&metadata.status); let ended = is_upstream_ended(&metadata.status);
if ended != stale.upstream_ended { if ended != stale.upstream_ended {
sqlx::query!( sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>, poster_path: Option<String>,
backdrop_path: Option<String>, backdrop_path: Option<String>,
vote_average: Option<f64>, vote_average: Option<f64>,
runtime_minutes: Option<i64>,
} }
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, /// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would /// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
assert_eq!(vote, Some(8.417)); assert_eq!(vote, Some(8.417));
} }
/// §5.5: the refresh stores the minutes-per-episode the size bands scale
/// by, and a later refresh with TMDB's frequently-empty
/// `episode_run_time` never blanks a known value.
#[tokio::test]
async fn refresh_stores_the_episode_runtime_and_keeps_it_over_blanks() {
let (_dir, database) = seeded_series(false).await;
let server = MockServer::start().await;
let body = |episode_run_time: serde_json::Value| {
json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"episode_run_time": episode_run_time,
"seasons": []
})
};
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([7]))))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
server.reset().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(body(json!([]))))
.mount(&server)
.await;
expire_refresh(&database).await;
action(&server).tick(&database).await.unwrap();
let runtime: Option<i64> =
sqlx::query_scalar("SELECT runtime_minutes FROM series WHERE tmdb_id = 82728")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(runtime, Some(7));
}
/// #160. A series' first refresh reveals its back catalogue, but §4.1 /// #160. A series' first refresh reveals its back catalogue, but §4.1
/// never tracks what was already there at add time: nothing is tracked, /// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted. /// nothing arrives wanted.
@@ -0,0 +1,6 @@
-- §5.5 as amended by #187/#208: size bands are rates against a 45-minute
-- reference runtime, scaled by the series' minutes per episode. NULL is a
-- missing runtime — TMDB's episode_run_time is frequently empty — and means
-- the bands apply unscaled.
ALTER TABLE series ADD COLUMN runtime_minutes INTEGER
CHECK (runtime_minutes IS NULL OR runtime_minutes > 0);
+2 -1
View File
@@ -696,7 +696,8 @@ mod tests {
&loaded.policy, &loaded.policy,
Resolution::R1080p, Resolution::R1080p,
one_and_a_half_gib, one_and_a_half_gib,
1 1,
0
), ),
Some(false) Some(false)
); );
+10
View File
@@ -156,6 +156,10 @@ pub struct Series {
pub vote_average: Option<f64>, pub vote_average: Option<f64>,
/// How many votes the rating rests on. /// How many votes the rating rests on.
pub vote_count: u32, pub vote_count: u32,
/// Minutes per episode, the first non-zero entry of TMDB's
/// `episode_run_time`. Frequently empty for returning series — §5.5
/// treats a missing runtime as the reference runtime.
pub episode_runtime: Option<u32>,
pub seasons: Vec<SeasonSummary>, pub seasons: Vec<SeasonSummary>,
} }
@@ -368,6 +372,8 @@ pub(crate) struct RawSeries {
#[serde(default)] #[serde(default)]
vote_count: u32, vote_count: u32,
#[serde(default)] #[serde(default)]
episode_run_time: Vec<u32>,
#[serde(default)]
seasons: Vec<RawSeasonSummary>, seasons: Vec<RawSeasonSummary>,
#[serde(default)] #[serde(default)]
external_ids: Option<RawExternalIds>, external_ids: Option<RawExternalIds>,
@@ -406,6 +412,10 @@ impl From<RawSeries> for Series {
backdrop_path: non_empty(raw.backdrop_path), backdrop_path: non_empty(raw.backdrop_path),
vote_average: rating(raw.vote_average), vote_average: rating(raw.vote_average),
vote_count: raw.vote_count, vote_count: raw.vote_count,
episode_runtime: raw
.episode_run_time
.into_iter()
.find(|&minutes| minutes > 0),
seasons: raw seasons: raw
.seasons .seasons
.into_iter() .into_iter()