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
+31
View File
@@ -1192,6 +1192,7 @@ pub(crate) async fn store_release(
original_language: &Language,
blacklist: &Blacklist,
) -> 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(
database,
release,
@@ -1200,6 +1201,7 @@ pub(crate) async fn store_release(
original_language,
blacklist,
1,
0,
)
.await?;
sqlx::query!(
@@ -1241,6 +1243,11 @@ pub(crate) async fn store_episode_release(
_ => BTreeMap::new(),
};
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(
database,
release,
@@ -1249,6 +1256,7 @@ pub(crate) async fn store_episode_release(
original_language,
blacklist,
episode_count,
runtime_minutes,
)
.await?;
for episode_id in episode_ids {
@@ -1293,6 +1301,26 @@ async fn season_lengths_of(
.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(
database: &Db,
release: &SearchRelease,
@@ -1301,6 +1329,7 @@ async fn classify_and_store(
original_language: &Language,
blacklist: &Blacklist,
episode_count: u32,
runtime_minutes: u32,
) -> Result<(i64, Option<Eligible>), GrabError> {
let parsed = arr_parse::parse(&release.name);
let evaluation = evaluate(
@@ -1310,6 +1339,7 @@ async fn classify_and_store(
Candidate::PreGrab(&parsed),
release.size,
episode_count,
runtime_minutes,
);
let scored = score(
policy,
@@ -1317,6 +1347,7 @@ async fn classify_and_store(
release.size.unwrap_or_default(),
release.seeders.unwrap_or_default(),
episode_count,
runtime_minutes,
);
// 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
+14
View File
@@ -308,6 +308,7 @@ impl ImportAction {
Candidate::PostDownload(&feature.media),
Some(feature.size),
1,
0,
);
let waiver: Option<Rule> = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -477,6 +478,10 @@ impl ImportAction {
// §5.6 second phase of truth, over every file that would be
// imported, before anything is placed: one hard failure condemns
// 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();
for assignment in assignments {
if assignment.episode.has_file {
@@ -496,6 +501,7 @@ impl ImportAction {
Candidate::PostDownload(&assignment.file.media),
Some(assignment.file.size),
1,
runtime_minutes,
);
let waiver = match evaluation.verdict {
Verdict::Rejected(rule) => {
@@ -938,6 +944,10 @@ struct PendingTvImport {
series_title: String,
series_year: Option<i64>,
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,
}
@@ -974,6 +984,7 @@ async fn pending_tv_imports(database: &Db) -> Result<Vec<PendingTvImport>, Impor
s.title AS "series_title!: String",
s.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
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_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
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.year AS "series_year",
s.original_language,
s.runtime_minutes,
r.name AS "release_name!: String"
FROM grabs g
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_year: row.series_year,
original_language: row.original_language,
runtime_minutes: row.runtime_minutes,
release_name: row.release_name,
}));
+66 -2
View File
@@ -75,7 +75,7 @@ impl SeriesRefreshAction {
title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
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
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,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
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 = ?"#,
series_id
)
@@ -250,6 +250,20 @@ impl SeriesRefreshAction {
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);
if ended != stale.upstream_ended {
sqlx::query!(
@@ -547,6 +561,7 @@ struct DueSeries {
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
runtime_minutes: Option<i64>,
}
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would
@@ -817,6 +832,55 @@ mod tests {
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
/// never tracks what was already there at add time: nothing is tracked,
/// nothing arrives wanted.