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
+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");