Merge main into blitz/subtitles
Feedback pass 2 and the size-band work landed on main while this branch was finishing. Brings them in ahead of the merge back. # Conflicts: # crates/arr-api/src/movies.rs # crates/arr-api/src/state.rs # crates/arr-daemon/src/main.rs # web/src/main.ts
This commit is contained in:
@@ -242,6 +242,14 @@ pub struct Policy {
|
||||
pub struct TitleOverrides {
|
||||
pub only_4k: bool,
|
||||
pub allow_english_audio: bool,
|
||||
/// Take a release below its size band's floor on this title (§5.5).
|
||||
///
|
||||
/// No band is right for every title, and the floor is a hard reject, so
|
||||
/// a title the band is wrong about has nothing grabbable at all. This
|
||||
/// relaxes the floor to a soft fail rather than removing it: the release
|
||||
/// is waived, never eligible, so it stays a deliberate manual grab and
|
||||
/// imports on the record as a §5.7 waiver.
|
||||
pub allow_below_floor: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
|
||||
@@ -68,6 +68,11 @@ pub struct EvaluationContext<'a> {
|
||||
/// 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,
|
||||
/// The series' minutes per episode (`DESIGN.md` §5.5) — a size band is a
|
||||
/// rate against 45 minutes, so the size rule scales its floor by
|
||||
/// `runtime / 45`. Zero is a missing runtime and applies the band
|
||||
/// unscaled; movies are never scaled and pass zero.
|
||||
pub runtime_minutes: u32,
|
||||
}
|
||||
|
||||
/// A rule's identity when no violation exists to carry concrete evidence.
|
||||
@@ -112,6 +117,7 @@ pub fn evaluate(
|
||||
candidate: Candidate<'_>,
|
||||
size_bytes: Option<u64>,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Evaluation {
|
||||
let context = EvaluationContext {
|
||||
policy,
|
||||
@@ -120,6 +126,7 @@ pub fn evaluate(
|
||||
candidate,
|
||||
size_bytes,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
};
|
||||
let rules: [&dyn PolicyRule; 6] = [
|
||||
&ResolutionRule,
|
||||
@@ -224,6 +231,12 @@ impl PolicyRule for SourceRule {
|
||||
/// "smaller is better" selects a 3 GB 4K encode that looks like mud. How far
|
||||
/// a candidate sits from the band's *target* is [`crate::score`]'s question,
|
||||
/// not this rule's.
|
||||
///
|
||||
/// `allow_below_floor` softens the floor for one title rather than lifting
|
||||
/// it: a below-floor release becomes a waiver, so it is never picked
|
||||
/// automatically and its import is recorded as a §5.7 waiver. This mirrors
|
||||
/// [`ResolutionRule`], where an override moves a failure between hard and
|
||||
/// soft and never makes the rule stop applying.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SizeRule;
|
||||
|
||||
@@ -233,9 +246,17 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count)
|
||||
{
|
||||
match crate::score::is_below_floor(
|
||||
context.policy,
|
||||
resolution,
|
||||
size,
|
||||
context.episode_count,
|
||||
context.runtime_minutes,
|
||||
) {
|
||||
None => RuleEvaluation::Unknown(RuleKind::Size),
|
||||
Some(true) if context.overrides.allow_below_floor => {
|
||||
RuleEvaluation::SoftFail(Rule::Size)
|
||||
}
|
||||
Some(true) => RuleEvaluation::HardFail(Rule::Size),
|
||||
Some(false) => RuleEvaluation::Pass(RuleKind::Size),
|
||||
}
|
||||
@@ -522,7 +543,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict {
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1).verdict
|
||||
evaluate(policy, overrides, &en(), candidate, None, 1, 0).verdict
|
||||
}
|
||||
|
||||
fn verdict_for(
|
||||
@@ -537,6 +558,7 @@ mod tests {
|
||||
candidate,
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict
|
||||
}
|
||||
@@ -552,6 +574,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -655,6 +678,179 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A policy with one 1080p band, so the floor has something to say.
|
||||
fn banded_policy() -> Policy {
|
||||
Policy {
|
||||
size_bands: BTreeMap::from([(
|
||||
Resolution::R1080p,
|
||||
crate::SizeBand {
|
||||
floor_bytes: 2 << 30,
|
||||
target_bytes: 4 << 30,
|
||||
penalty_points_per_gib_over: 600,
|
||||
},
|
||||
)]),
|
||||
resolution_preference: vec![Resolution::R1080p],
|
||||
..policy()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_below_floor_release_is_rejected_without_the_override() {
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
let evaluation = evaluate(
|
||||
&banded_policy(),
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(evaluation.verdict, Verdict::Rejected(Rule::Size));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_below_floor_waives_the_floor_rather_than_lifting_it() {
|
||||
let policy = banded_policy();
|
||||
let overrides = TitleOverrides {
|
||||
allow_below_floor: true,
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
|
||||
// Pre-grab the deck offers it, and only as a waiver: `waived` is
|
||||
// never picked automatically (§9.3), so the operator still decides.
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
);
|
||||
|
||||
// And the import records the waiver rather than hard-failing (§5.7).
|
||||
let media = probed(Resolution::R1080p, Some(Source::WebDl));
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
Some(1 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_below_floor_says_nothing_about_a_release_that_clears_the_floor() {
|
||||
let overrides = TitleOverrides {
|
||||
allow_below_floor: true,
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&banded_policy(),
|
||||
&overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(4 << 30),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.5: the floor takes the per-episode figure, so the override that
|
||||
/// relaxes it has to travel the same divisor.
|
||||
#[test]
|
||||
fn allow_below_floor_waives_a_pack_measured_per_episode() {
|
||||
let policy = banded_policy();
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
// Ten episodes at 1 GiB each: over the floor in total, under it per
|
||||
// episode, which is the figure the floor compares.
|
||||
let size = Some(10 << 30);
|
||||
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size,
|
||||
10,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Rejected(Rule::Size)
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate(
|
||||
&policy,
|
||||
&TitleOverrides {
|
||||
allow_below_floor: true,
|
||||
..TitleOverrides::default()
|
||||
},
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
size,
|
||||
10,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
/// §5.5 scaling composes with the #210 waiver: the runtime moves the
|
||||
/// floor, and `allow_below_floor` still only softens what remains below
|
||||
/// it — it never bypasses the scaled comparison.
|
||||
#[test]
|
||||
fn allow_below_floor_waives_against_the_scaled_floor() {
|
||||
let policy = banded_policy();
|
||||
let waive = TitleOverrides {
|
||||
allow_below_floor: true,
|
||||
..TitleOverrides::default()
|
||||
};
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
// The 2 GiB floor at 22 minutes is ~0.98 GiB. 1.5 GiB clears it, so
|
||||
// the override has nothing to waive; 0.5 GiB is below even the
|
||||
// scaled floor and stays a waiver rather than eligible.
|
||||
let at = |size_bytes, overrides| {
|
||||
evaluate(
|
||||
&policy,
|
||||
overrides,
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(size_bytes),
|
||||
1,
|
||||
22,
|
||||
)
|
||||
.verdict
|
||||
};
|
||||
|
||||
assert_eq!(at(3 << 29, &waive), Verdict::Eligible);
|
||||
assert_eq!(at(1 << 29, &waive), Verdict::Waived(Rule::Size));
|
||||
assert_eq!(
|
||||
at(1 << 29, &TitleOverrides::default()),
|
||||
Verdict::Rejected(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unsafe_source_hard_fails_in_both_phases() {
|
||||
let policy = policy();
|
||||
@@ -735,6 +931,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -792,6 +989,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
let expected = if rejected {
|
||||
Verdict::Rejected(Rule::DolbyVisionProfile(profile))
|
||||
@@ -812,6 +1010,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
@@ -845,6 +1044,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let soft = FixedRule {
|
||||
evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())),
|
||||
@@ -874,6 +1074,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: None,
|
||||
episode_count: 1,
|
||||
runtime_minutes: 0,
|
||||
};
|
||||
let first = FixedRule {
|
||||
evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())),
|
||||
@@ -1010,7 +1211,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Eligible
|
||||
@@ -1024,7 +1226,8 @@ mod tests {
|
||||
&en(),
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1
|
||||
1,
|
||||
0,
|
||||
)
|
||||
.verdict,
|
||||
Verdict::Waived(Rule::RequiredAudio)
|
||||
@@ -1102,6 +1305,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
|
||||
@@ -1114,6 +1318,7 @@ mod tests {
|
||||
Candidate::PostDownload(&media),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified));
|
||||
}
|
||||
@@ -1128,6 +1333,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
None,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
assert_eq!(report.verdict, Verdict::Eligible);
|
||||
assert_eq!(
|
||||
|
||||
+193
-22
@@ -27,6 +27,11 @@ use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source};
|
||||
|
||||
const BYTES_PER_GIB: i64 = 1 << 30;
|
||||
|
||||
/// The reference runtime (`DESIGN.md` §5.5): a band's shipped values are a
|
||||
/// rate against a 45-minute episode, and both floor and target scale by
|
||||
/// `runtime / 45` before a per-episode size is compared to them.
|
||||
pub const REFERENCE_RUNTIME_MINUTES: u32 = 45;
|
||||
|
||||
/// How much each scoring term is worth. Policy data, not constants in the
|
||||
/// code, for the same reason the size bands are.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -94,6 +99,13 @@ pub struct Score {
|
||||
/// 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.
|
||||
///
|
||||
/// A band also describes a rate against [`REFERENCE_RUNTIME_MINUTES`], so
|
||||
/// `runtime_minutes` — the series' minutes per episode, caller-supplied the
|
||||
/// same way — scales its floor and target before the comparison. Zero is a
|
||||
/// missing runtime and applies the band unscaled, exactly the pre-scaling
|
||||
/// behaviour. Movies are never scaled: their bands are already tuned against
|
||||
/// feature length, so a movie caller passes zero.
|
||||
#[must_use]
|
||||
pub fn score(
|
||||
policy: &Policy,
|
||||
@@ -101,13 +113,16 @@ pub fn score(
|
||||
size_bytes: u64,
|
||||
seeders: u32,
|
||||
episode_count: u32,
|
||||
runtime_minutes: 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, per_episode));
|
||||
.map_or(0, |band| {
|
||||
size_points(&scaled_band(band, runtime_minutes), weights, per_episode)
|
||||
});
|
||||
let source = candidate
|
||||
.source()
|
||||
.map_or(0, |source| source_points(policy, source));
|
||||
@@ -130,13 +145,19 @@ pub fn score(
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
#[must_use]
|
||||
pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score {
|
||||
pub fn score_release(
|
||||
policy: &Policy,
|
||||
release: &Release,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Score {
|
||||
score(
|
||||
policy,
|
||||
Candidate::PreGrab(&release.parsed),
|
||||
release.size,
|
||||
release.seeders,
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -146,6 +167,9 @@ pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) ->
|
||||
/// (`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.
|
||||
///
|
||||
/// The floor is also scaled by `runtime_minutes / 45` the way [`score`]
|
||||
/// scales it: zero runtime means unscaled, and a movie caller passes zero.
|
||||
///
|
||||
/// `None` when the policy carries no band for that resolution: no band is no
|
||||
/// opinion, not a rejection.
|
||||
#[must_use]
|
||||
@@ -154,11 +178,11 @@ pub fn is_below_floor(
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Option<bool> {
|
||||
policy
|
||||
.size_bands
|
||||
.get(&resolution)
|
||||
.map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes)
|
||||
policy.size_bands.get(&resolution).map(|band| {
|
||||
per_episode_size(size_bytes, episode_count) < scaled_band(band, runtime_minutes).floor_bytes
|
||||
})
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
@@ -195,6 +219,26 @@ fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 {
|
||||
size_bytes / u64::from(episode_count.max(1))
|
||||
}
|
||||
|
||||
/// A band read at a runtime (`DESIGN.md` §5.5): floor and target scale by
|
||||
/// `runtime / 45`, the penalty rate stays per gibibyte over. Zero runtime is
|
||||
/// the reference runtime — the band applies unscaled.
|
||||
fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> SizeBand {
|
||||
if runtime_minutes == 0 || runtime_minutes == REFERENCE_RUNTIME_MINUTES {
|
||||
return *band;
|
||||
}
|
||||
SizeBand {
|
||||
floor_bytes: scale_by_runtime(band.floor_bytes, runtime_minutes),
|
||||
target_bytes: scale_by_runtime(band.target_bytes, runtime_minutes),
|
||||
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_by_runtime(bytes: u64, runtime_minutes: u32) -> u64 {
|
||||
let scaled =
|
||||
u128::from(bytes) * u128::from(runtime_minutes) / u128::from(REFERENCE_RUNTIME_MINUTES);
|
||||
u64::try_from(scaled).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The size term: a ramp from the floor up to the target, then a penalty that
|
||||
/// grows with every gigabyte above it.
|
||||
fn size_points(band: &SizeBand, weights: &ScoreWeights, size_bytes: u64) -> i64 {
|
||||
@@ -329,6 +373,7 @@ mod tests {
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,6 +395,7 @@ mod tests {
|
||||
size_bytes,
|
||||
seeders,
|
||||
1,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,6 +404,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation {
|
||||
size_rule_at(size_bytes, episode_count, 0)
|
||||
}
|
||||
|
||||
fn size_rule_at(size_bytes: u64, episode_count: u32, runtime_minutes: u32) -> RuleEvaluation {
|
||||
let policy = policy();
|
||||
let overrides = TitleOverrides::default();
|
||||
let language = Language::Other("en".to_owned());
|
||||
@@ -369,6 +419,7 @@ mod tests {
|
||||
candidate: Candidate::PreGrab(&claims),
|
||||
size_bytes: Some(size_bytes),
|
||||
episode_count,
|
||||
runtime_minutes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -400,6 +451,7 @@ mod tests {
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(gib(60)),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
@@ -466,11 +518,11 @@ 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), 1),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1),
|
||||
is_below_floor(&policy(), Resolution::R720p, gib(3), 1, 0),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -481,7 +533,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1);
|
||||
let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1, 0);
|
||||
|
||||
assert_eq!(score.size, 0);
|
||||
assert_eq!(score.total, score.source + score.seeders);
|
||||
@@ -499,8 +551,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1);
|
||||
let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1, 0);
|
||||
let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1, 0);
|
||||
|
||||
assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target));
|
||||
assert_eq!(under.size, 0);
|
||||
@@ -548,7 +600,7 @@ mod tests {
|
||||
resolution: Some(resolution),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution
|
||||
score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1, 0).resolution
|
||||
};
|
||||
|
||||
assert_eq!(at(ClaimedResolution::P2160), 2 * step);
|
||||
@@ -565,7 +617,7 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1);
|
||||
let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1, 0);
|
||||
|
||||
assert_eq!(unranked.resolution, 0);
|
||||
assert_eq!(unclaimed.resolution, 0);
|
||||
@@ -617,7 +669,7 @@ mod tests {
|
||||
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);
|
||||
let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10, 0);
|
||||
|
||||
assert_eq!(pack.size, episode.size);
|
||||
assert_eq!(pack.total, episode.total);
|
||||
@@ -628,7 +680,7 @@ mod tests {
|
||||
// 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),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 10, 0),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -638,22 +690,141 @@ mod tests {
|
||||
|
||||
// The same total over three episodes is 10 GiB each and passes.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(30), 3, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_)));
|
||||
}
|
||||
|
||||
/// The corrected acceptance criterion from issue #209: a series whose
|
||||
/// runtime is known and short is judged against a proportionally scaled
|
||||
/// floor and target, at both 22 and 45 minutes.
|
||||
#[test]
|
||||
fn a_known_short_runtime_scales_the_floor_at_22_and_45_minutes() {
|
||||
// 4K floor is 8 GiB per 45 minutes; at 22 minutes it is ~3.91 GiB.
|
||||
// 5 GiB fails the unscaled floor and clears the 22-minute one.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 22),
|
||||
Some(false)
|
||||
);
|
||||
// Genuinely thin stays rejected even scaled: 3 GiB < 3.91 GiB.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 22),
|
||||
Some(true)
|
||||
);
|
||||
// 45 minutes is the reference runtime: identical to no scaling.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 45),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(5), 1, 0)
|
||||
);
|
||||
|
||||
// The size rule takes the same scaled floor.
|
||||
assert_eq!(
|
||||
size_rule_at(gib(5), 1, 0),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
assert_eq!(
|
||||
size_rule_at(gib(5), 1, 45),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
assert!(matches!(
|
||||
size_rule_at(gib(5), 1, 22),
|
||||
RuleEvaluation::Pass(_)
|
||||
));
|
||||
assert_eq!(
|
||||
size_rule_at(gib(3), 1, 22),
|
||||
RuleEvaluation::HardFail(Rule::Size)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_target_scales_with_runtime_so_equal_bitrates_score_equally() {
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let scored_at_runtime = |size, runtime| {
|
||||
score(&policy(), Candidate::PreGrab(&claims), size, 10, 1, runtime).size
|
||||
};
|
||||
|
||||
// At-target bitrate: 22 GiB per 45 minutes is 22 GiB × 22/45 at 22
|
||||
// minutes, and both sit at the top of the size term.
|
||||
let at_target = i64::from(ScoreWeights::default().size_at_target);
|
||||
assert_eq!(scored_at_runtime(gib(22), 45), at_target);
|
||||
assert_eq!(scored_at_runtime(gib(22) * 22 / 45, 22), at_target);
|
||||
|
||||
// A below-target bitrate lands on the same point of the ramp at any
|
||||
// runtime, give or take integer rounding.
|
||||
let half_way_45 = scored_at_runtime(gib(15), 45);
|
||||
let half_way_22 = scored_at_runtime(gib(15) * 22 / 45, 22);
|
||||
assert!((half_way_45 - half_way_22).abs() <= 1);
|
||||
}
|
||||
|
||||
/// The correction on issue #209: the Rick and Morty S09 packs are 0.19,
|
||||
/// 0.24 and 0.32 GiB per 22-minute episode against a 1 GiB 1080p floor.
|
||||
/// The scaled floor is ~0.489 GiB, they are genuinely low-bitrate, and
|
||||
/// scaling must not let them through.
|
||||
#[test]
|
||||
fn the_rick_and_morty_s09_packs_stay_below_the_scaled_floor() {
|
||||
let mut policy = policy();
|
||||
policy.size_bands.insert(
|
||||
Resolution::R1080p,
|
||||
SizeBand {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
},
|
||||
);
|
||||
let episodes = 10;
|
||||
for per_episode_gib in [19, 24, 32] {
|
||||
let pack = per_episode_gib * GIB / 100 * u64::from(episodes);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, pack, episodes, 22),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
// Half a GiB per episode clears the scaled floor: the floor still
|
||||
// discriminates rather than rejecting every 22-minute release.
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R1080p, gib(5), episodes, 22),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing runtime is the reference runtime (`DESIGN.md` §5.5): zero
|
||||
/// reproduces the pre-scaling score exactly, pinned to literals the same
|
||||
/// way #180 pinned movie scoring.
|
||||
#[test]
|
||||
fn a_missing_runtime_reproduces_the_unscaled_score() {
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
let missing = score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 0);
|
||||
assert_eq!(
|
||||
missing,
|
||||
Score {
|
||||
total: 918,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
missing,
|
||||
score(&policy(), Candidate::PreGrab(&claims), gib(30), 40, 1, 45)
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0, 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)
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 0, 0),
|
||||
is_below_floor(&policy(), Resolution::R2160p, gib(3), 1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -733,18 +904,18 @@ mod tests {
|
||||
source: Some(ClaimedSource::WebDl),
|
||||
..NameClaims::default()
|
||||
};
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes)
|
||||
score(&policy, Candidate::PreGrab(&claims), size, 20, episodes, 0)
|
||||
};
|
||||
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),
|
||||
is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes),
|
||||
is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes, 0),
|
||||
Some(false)
|
||||
);
|
||||
assert!(uhd.total > hd.total);
|
||||
|
||||
Reference in New Issue
Block a user