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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user