feat(core): score season packs per episode

A size band describes one episode (DESIGN.md §5.5), so both the target
penalty and the floor now compare a release's size divided by the number
of episodes it covers. The caller supplies the count — arr-core has no
IO. Movies and unknown counts divide by one, so movie scoring is
unchanged and pinned by test.

Closes #180

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 20:10:23 +01:00
parent 577eef3f31
commit 2286cba862
11 changed files with 502 additions and 39 deletions
+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) => {