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