+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d"
|
||||
}
|
||||
+19
-7
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"name": "series_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "series",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "series_title!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -16,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"name": "series_tvdb_id",
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -27,7 +38,7 @@
|
||||
},
|
||||
{
|
||||
"name": "original_language",
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -38,7 +49,7 @@
|
||||
},
|
||||
{
|
||||
"name": "season_number!: i64",
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -49,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"name": "episode_number!: i64",
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
@@ -63,6 +74,7 @@
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -70,5 +82,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
|
||||
"hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "number!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "seasons",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "episodes!: i64",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": "Expression"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51"
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct EvaluationContext<'a> {
|
||||
/// when the caller has no size to offer, which abstains rather than
|
||||
/// rejects.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band
|
||||
/// describes one episode, so the size rule divides by this. One for a
|
||||
/// movie or an unknown count; zero is treated as one.
|
||||
pub episode_count: u32,
|
||||
}
|
||||
|
||||
/// A rule's identity when no violation exists to carry concrete evidence.
|
||||
@@ -107,6 +111,7 @@ pub fn evaluate(
|
||||
original_language: &Language,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
episode_count: u32,
|
||||
) -> Evaluation {
|
||||
let context = EvaluationContext {
|
||||
policy,
|
||||
@@ -114,6 +119,7 @@ pub fn evaluate(
|
||||
original_language,
|
||||
candidate,
|
||||
size_bytes,
|
||||
episode_count,
|
||||
};
|
||||
let rules: [&dyn PolicyRule; 6] = [
|
||||
&ResolutionRule,
|
||||
@@ -227,7 +233,8 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
match crate::score::is_below_floor(context.policy, resolution, size) {
|
||||
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
|
||||
{
|
||||
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
||||
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
|
||||
@@ -515,7 +522,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None).verdict
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1).verdict
|
||||
}
|
||||
|
||||
fn verdict_for(
|
||||
@@ -529,6 +536,7 @@ mod tests {
|
||||
original_language,
|
||||
candidate,
|
||||
None,
|
||||
1,
|
||||
)
|
||||
.verdict
|
||||
}
|
||||
@@ -543,6 +551,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -725,6 +734,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -781,6 +791,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
let expected = if rejected {
|
||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||
@@ -800,6 +811,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -832,6 +844,7 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
};
|
||||
let soft = FixedRule {
|
||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||
@@ -860,6 +873,7 @@ mod tests {
|
||||
original_language: &original,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
};
|
||||
let first = FixedRule {
|
||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||
@@ -995,7 +1009,8 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None
|
||||
None,
|
||||
1
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -1008,7 +1023,8 @@ mod tests {
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None
|
||||
None,
|
||||
1
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::RequiredAudio)
|
||||
@@ -1082,6 +1098,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
|
||||
@@ -1093,6 +1110,7 @@ mod tests {
|
||||
&Language::PortugueseBrazil,
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
}
|
||||
@@ -1106,6 +1124,7 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
assert_eq!(
|
||||
|
||||
+263
-14
@@ -19,6 +19,10 @@
|
||||
//! remux playback test in `DESIGN.md` §14; the model is the decision, not the
|
||||
//! constants.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use arr_parse::EpisodeClaim;
|
||||
|
||||
use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
|
||||
|
||||
const BYTES_PER_GIB: i64 = 1 << 30;
|
||||
@@ -84,13 +88,26 @@ pub struct Score {
|
||||
/// resolution the policy carries no band for — scores zero on the dominant
|
||||
/// term rather than being penalised for what the release name failed to say.
|
||||
/// The resolution-rank term treats an unclaimed resolution the same way.
|
||||
///
|
||||
/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the
|
||||
/// divisor that turns a season pack's total into the per-episode figure the
|
||||
/// band is compared against. The caller supplies it — `arr-core` has no IO,
|
||||
/// and a `Season` claim names a season, not a length. A movie, and any release
|
||||
/// whose count is unknown, is one episode; zero is treated as one.
|
||||
#[must_use]
|
||||
pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score {
|
||||
pub fn score(
|
||||
policy: &Policy,
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: u64,
|
||||
seeders: u32,
|
||||
episode_count: u32,
|
||||
) -> Score {
|
||||
let weights = &policy.score_weights;
|
||||
let claimed = candidate.resolution();
|
||||
let per_episode = per_episode_size(size_bytes, episode_count);
|
||||
let size = claimed
|
||||
.and_then(|resolution| policy.size_bands.get(&resolution))
|
||||
.map_or(0, |band| size_points(band, weights, size_bytes));
|
||||
.map_or(0, |band| size_points(band, weights, per_episode));
|
||||
let source = candidate
|
||||
.source()
|
||||
.map_or(0, |source| source_points(policy, source));
|
||||
@@ -113,25 +130,69 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
#[must_use]
|
||||
pub fn score_release(policy: &Policy, release: &Release) -> Score {
|
||||
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score {
|
||||
score(
|
||||
policy,
|
||||
Candidate::PreGrab(&release.parsed),
|
||||
release.size,
|
||||
release.seeders,
|
||||
episode_count,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a size falls below its band's floor, which is a hard filter.
|
||||
///
|
||||
/// The floor takes the same per-episode figure as the target penalty
|
||||
/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor
|
||||
/// would let every pack through untested. Zero `episode_count` is one episode.
|
||||
///
|
||||
/// `None` when the policy carries no band for that resolution: no band is no
|
||||
/// opinion, not a rejection.
|
||||
#[must_use]
|
||||
pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option<bool> {
|
||||
pub fn is_below_floor(
|
||||
policy: &Policy,
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
) -> Option<bool> {
|
||||
policy
|
||||
.size_bands
|
||||
.get(&resolution)
|
||||
.map(|band| size_bytes < band.floor_bytes)
|
||||
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
/// behind [`score`] and [`is_below_floor`].
|
||||
///
|
||||
/// `season_lengths` maps season number to that season's episode count as the
|
||||
/// caller knows it — the caller supplies the fact the way `apply_auto_track`
|
||||
/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes`
|
||||
/// claim knows its own length; a `Season` or `Seasons` claim is summed from
|
||||
/// the map. Anything unknown — no claim, a daily episode, a season the map
|
||||
/// does not carry — falls back to one episode, which measures the release by
|
||||
/// its full size and fails toward rejecting a good pack rather than grabbing
|
||||
/// a bad one.
|
||||
#[must_use]
|
||||
pub fn claimed_episode_count(
|
||||
claim: Option<&EpisodeClaim>,
|
||||
season_lengths: &BTreeMap<u32, u32>,
|
||||
) -> u32 {
|
||||
let count = match claim {
|
||||
Some(EpisodeClaim::Episodes { episodes, .. }) => {
|
||||
u32::try_from(episodes.len()).unwrap_or(u32::MAX)
|
||||
}
|
||||
Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0),
|
||||
Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last)
|
||||
.map(|season| season_lengths.get(&season).copied().unwrap_or(0))
|
||||
.fold(0u32, u32::saturating_add),
|
||||
Some(EpisodeClaim::Daily { .. }) | None => 1,
|
||||
};
|
||||
count.max(1)
|
||||
}
|
||||
|
||||
/// The per-episode figure every size band is compared against.
|
||||
fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
|
||||
size_bytes / u64::from(episode_count.max(1))
|
||||
}
|
||||
|
||||
/// The size term: a ramp from the floor up to the target, then a penalty that
|
||||
@@ -262,7 +323,13 @@ mod tests {
|
||||
|
||||
fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score {
|
||||
let claims = claims(source);
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
/// The same, at a resolution the caller picks.
|
||||
@@ -277,10 +344,20 @@ mod tests {
|
||||
source: Some(source),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders)
|
||||
score(
|
||||
&policy(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
fn size_rule(size_bytes: u64) -> RuleEvaluation {
|
||||
size_rule_for(size_bytes, 1)
|
||||
}
|
||||
|
||||
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
@@ -291,6 +368,7 @@ mod tests {
|
||||
original_language: &language,
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: Some(size_bytes),
|
||||
episode_count,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -321,6 +399,7 @@ mod tests {
|
||||
&Language::Other("en".to_owned()),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
1,
|
||||
);
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
@@ -387,10 +466,13 @@ mod tests {
|
||||
assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size));
|
||||
assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_)));
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3)),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +481,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10);
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
|
||||
|
||||
assert_eq!(score.size, 0);
|
||||
assert_eq!(score.total, score.source + score.seeders);
|
||||
@@ -417,8 +499,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
|
||||
|
||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||
assert_eq!(under.size, 0);
|
||||
@@ -466,7 +548,7 @@ mod tests {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
@@ -483,7 +565,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10);
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
@@ -500,4 +582,171 @@ mod tests {
|
||||
assert!(hd.seeders > uhd.seeders);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
|
||||
/// Issue #180: a movie passes one episode's worth and must score exactly
|
||||
/// as it did before packs learned to divide. Every term pinned to a
|
||||
/// literal so the normalisation cannot quietly move movie scoring.
|
||||
#[test]
|
||||
fn a_movie_scores_exactly_as_before_pack_normalisation() {
|
||||
let at_target = scored(ClaimedSource::WebDl, gib(22), 40);
|
||||
assert_eq!(
|
||||
at_target,
|
||||
Score {
|
||||
total: 1398,
|
||||
size: 1000,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
|
||||
let over_target = scored(ClaimedSource::WebDl, gib(30), 40);
|
||||
assert_eq!(
|
||||
over_target,
|
||||
Score {
|
||||
total: 918,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() {
|
||||
let episode = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10);
|
||||
|
||||
assert_eq!(pack.size, episode.size);
|
||||
assert_eq!(pack.total, episode.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_below_the_floor_per_episode_is_rejected() {
|
||||
// 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor
|
||||
// — a pack of mud-quality encodes fails as plainly as one of them.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
size_rule_for(gib(30), 10),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
|
||||
// The same total over three episodes is 10 GiB each and passes.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
|
||||
Some(false)
|
||||
);
|
||||
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_episode_count_falls_back_to_one_episode() {
|
||||
let single = scored(ClaimedSource::WebDl, gib(22), 10);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0);
|
||||
|
||||
assert_eq!(zero, single);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() {
|
||||
let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]);
|
||||
|
||||
let episodes = EpisodeClaim::Episodes {
|
||||
season: 1,
|
||||
episodes: vec![2, 3, 4],
|
||||
};
|
||||
assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths),
|
||||
30
|
||||
);
|
||||
|
||||
// Unknowns are one episode: no claim, a daily episode, a season the
|
||||
// map has not revealed yet.
|
||||
assert_eq!(claimed_episode_count(None, &lengths), 1);
|
||||
assert_eq!(
|
||||
claimed_episode_count(
|
||||
Some(&EpisodeClaim::Daily {
|
||||
year: 2024,
|
||||
month: 5,
|
||||
day: 13
|
||||
}),
|
||||
&lengths
|
||||
),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// The observed case from issue #180: Rick and Morty S08 against a
|
||||
/// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB
|
||||
/// over, resolution step 300). Measured by pack total, the 1080p pack's
|
||||
/// smaller size penalty beat the 300-point resolution step; per episode,
|
||||
/// 2160p ranks first as `resolution_pref` says it should.
|
||||
#[test]
|
||||
fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() {
|
||||
let policy = Policy {
|
||||
size_bands: BTreeMap::from([
|
||||
(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: GIB / 4,
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
(
|
||||
Resolution::R2160p,
|
||||
SizeBand {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(6),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
),
|
||||
]),
|
||||
..self::policy()
|
||||
};
|
||||
let episodes = 10;
|
||||
let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB
|
||||
let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB
|
||||
|
||||
let scored = |resolution, size| {
|
||||
let claims = NameClaims {
|
||||
resolution: Some(resolution),
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
|
||||
};
|
||||
let hd = scored(ClaimedResolution::P1080, hd_pack);
|
||||
let uhd = scored(ClaimedResolution::P2160, uhd_pack);
|
||||
|
||||
// Neither pack trips the floor per episode, so the ranking decides.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
|
||||
Some(false)
|
||||
);
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -619,7 +619,12 @@ mod tests {
|
||||
|
||||
let one_and_a_half_gib = 1536 << 20;
|
||||
assert_eq!(
|
||||
arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib),
|
||||
arr_core::score::is_below_floor(
|
||||
&loaded.policy,
|
||||
Resolution::R1080p,
|
||||
one_and_a_half_gib,
|
||||
1
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user