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
+19 -4
View File
@@ -1,5 +1,7 @@
use std::collections::BTreeMap;
use arr_core::policy::Candidate;
use arr_core::score::score;
use arr_core::score::{claimed_episode_count, score};
use arr_core::{ParsedRelease, Policy};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
@@ -602,7 +604,7 @@ pub async fn releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::NotFound)?
.policy;
rescore(&mut releases, &policy)?;
rescore(&mut releases, &policy, None)?;
Ok(Json(releases))
}
@@ -611,18 +613,30 @@ pub async fn releases(
/// daemon's winner pick; the deck the user sees must reflect policy tuning
/// made since the release was last searched, not whatever was true then.
///
/// `season_lengths` maps season number to episode count for the deck's series
/// — a size band describes one episode, so a pack's size is divided by how
/// many it covers. `None` is the movie decks: one release is one film,
/// whatever episode-shaped noise its name parses to.
///
/// 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
/// column's storage type is exact.
#[allow(clippy::cast_precision_loss)]
pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> {
pub(crate) fn rescore(
releases: &mut [Release],
policy: &Policy,
season_lengths: Option<&BTreeMap<u32, u32>>,
) -> Result<(), ApiError> {
let mut totals = Vec::with_capacity(releases.len());
for release in releases.iter() {
let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone())
.map_err(|error| ApiError::Database(error.to_string()))?;
let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX);
let size = u64::try_from(release.size).unwrap_or(0);
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total);
let episodes = season_lengths.map_or(1, |lengths| {
claimed_episode_count(parsed.episode.as_ref(), lengths)
});
totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total);
}
let mut indices: Vec<usize> = (0..releases.len()).collect();
indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i]));
@@ -1430,6 +1444,7 @@ mod tests {
arr_core::policy::Candidate::PreGrab(&parsed),
size_bytes,
seeders,
1,
)
.total;
#[allow(clippy::cast_precision_loss)]
+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");
+34 -4
View File
@@ -13,7 +13,7 @@
//! - **Status is derived, never stored** (§4.2). Every series the API returns
//! carries a status computed from its episodes at request time.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
@@ -1360,7 +1360,7 @@ pub async fn episode_releases(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Release>>, ApiError> {
load_episode(&state, id).await?;
let episode = load_episode(&state, id).await?;
let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id)
.fetch_all(pool(&state)?)
.await?;
@@ -1372,7 +1372,8 @@ pub async fn episode_releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::EpisodeNotFound)?
.policy;
rescore(&mut releases, &policy)?;
let lengths = season_lengths(&state, episode.series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?;
Ok(Json(releases))
}
@@ -1407,6 +1408,34 @@ pub async fn grab_episode(
/// Resolves a season by its number within one series, so the deck is
/// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`).
/// Per-season episode counts for one series: the divisor data behind
/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no
/// revealed episodes counts zero, which `claimed_episode_count` treats as
/// unknown.
pub(crate) async fn season_lengths(
state: &AppState,
series_id: i64,
) -> Result<BTreeMap<u32, u32>, ApiError> {
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 = ?
GROUP BY se.number"#,
series_id
)
.fetch_all(pool(state)?)
.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 load_season_id(state: &AppState, series_id: i64, number: i64) -> Result<i64, ApiError> {
sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
@@ -1484,7 +1513,8 @@ pub async fn season_releases(
.map_err(|error| ApiError::Database(error.to_string()))?
.ok_or(ApiError::SeasonNotFound)?
.policy;
rescore(&mut releases, &policy)?;
let lengths = season_lengths(&state, series_id).await?;
rescore(&mut releases, &policy, Some(&lengths))?;
Ok(Json(releases))
}