feat: rank 4K first, fall back to good 1080p
Resolution preference becomes strict tiers (step 10 000) and every size band gains a ceiling that waives bloated releases, so a 60 GB remux cannot win the top tier unattended. Movie grab, TV grab, RSS and the search deck now share one ranking key from arr-core. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
abb7e0a489
commit
cd9e8a891c
@@ -290,9 +290,18 @@ in search results means "the name claims DV" and its absence means nothing.
|
||||
|
||||
### 5.4 Quality, and why there is no upgrade loop
|
||||
|
||||
Default: take 4K if it exists now, otherwise 1080p. Per-title `only_4k`
|
||||
rejects 1080p outright and keeps searching indefinitely — the escape hatch for
|
||||
the handful of titles worth waiting on.
|
||||
Default: take 4K if it exists now, otherwise a good 1080p. The resolution
|
||||
preference is a list of tiers, not a weighting: any eligible 4K outranks every
|
||||
1080p, and 1080p is only ever the fallback when no eligible 4K exists. "Good"
|
||||
means eligible under the same rules as 4K — inside its size band, between the
|
||||
floor and the ceiling (§5.5), with the right audio and a watchable source. A
|
||||
1080p that fails that bar is not taken, and the title keeps waiting. Per-title
|
||||
`only_4k` rejects 1080p outright and keeps searching indefinitely — the escape
|
||||
hatch for the handful of titles worth waiting on.
|
||||
|
||||
A 1080p taken as the fallback is final. The file row shows its resolution, and
|
||||
the manual search is how it becomes 4K later; nothing re-searches it on its
|
||||
own (below).
|
||||
|
||||
**No grab delay.** Deliberately rejected. Radarr's delay profile exists to stop
|
||||
first-match-wins from always yielding 1080p; here the `only_4k` flag solves the
|
||||
@@ -307,11 +316,18 @@ a marker in the UI and a manual "search again" button, not a background job.
|
||||
Source tier is **not** the dominant term. A remux should win only when nothing
|
||||
smaller exists.
|
||||
|
||||
Per resolution, a floor, a target and a growing penalty above target. For 4K:
|
||||
floor ~8 GB, target ~22 GB. A 20 GB WEB-DL sits at target and wins; a 60 GB
|
||||
remux scores badly but stays eligible, so it is picked when it is the only
|
||||
option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K
|
||||
encode that looks like mud.
|
||||
Per resolution, a floor, a target, a growing penalty above target and a
|
||||
ceiling. For 4K: floor ~8 GB, target ~22 GB, ceiling ~44 GB. A 20 GB WEB-DL
|
||||
sits at target and wins; a 34 GB BluRay scores worse but stays eligible. The
|
||||
floor matters — unbounded "smaller is better" selects a 3 GB 4K encode that
|
||||
looks like mud.
|
||||
|
||||
**The ceiling waives, it does not reject.** Strict resolution tiers would
|
||||
otherwise prefer a 60 GB remux over every good 1080p, which is a bloated
|
||||
download for something already watchable. Above the ceiling a release is
|
||||
`waived`: automatic selection never takes it, the manual deck still offers it.
|
||||
The ceiling is measured exactly as the floor is — per episode, scaled by
|
||||
runtime — and a band without one has no ceiling.
|
||||
|
||||
**A band describes one episode.** For a movie the question never arises — one
|
||||
release is one film. For TV a release may carry a single episode, several, or a
|
||||
@@ -372,12 +388,15 @@ entry is worth nothing and every earlier one a step more. A resolution the list
|
||||
does not carry scores nothing rather than being penalised, the same as an
|
||||
unclaimed resolution: no ranking is no opinion.
|
||||
|
||||
The step is sized against the size term, not chosen in isolation. With the
|
||||
seeded 4K band — target 22 GB, 60 points per gigabyte over — a step of 300 is
|
||||
five gigabytes of overshoot: a 4K up to about 27 GB beats an at-target 1080p,
|
||||
and a bloated 40 GB 4K does not. Below target the same arithmetic asks a 4K to
|
||||
be within about four gigabytes of its target to win, which is what keeps an 8 GB
|
||||
4K that looks like mud from beating a good 1080p.
|
||||
The step is a tier, not a trade-off (§5.4). At 10 000 points it is larger than
|
||||
everything the size, source and seeder terms can add or take away inside a
|
||||
band, so the step decides between resolutions and the other terms only order
|
||||
releases of the same one. The band's floor and ceiling are what keep a mud
|
||||
encode or a bloated remux out of the top tier, not the step.
|
||||
|
||||
Every selection — targeted movie search, TV search, RSS and the manual deck —
|
||||
orders candidates through one key in `arr-core`: score, then indexer id, then
|
||||
guid. The deck's top eligible row is the release an automatic grab would take.
|
||||
|
||||
Exact numbers are policy rows, tuned by hand. The model is the decision.
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct SizeBandSpec {
|
||||
pub floor_gib: u64,
|
||||
pub target_gib: u64,
|
||||
pub penalty_points_per_gib_over: i32,
|
||||
/// Above this a release is waived, never picked automatically. Absent
|
||||
/// is no ceiling.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ceiling_gib: Option<u64>,
|
||||
}
|
||||
|
||||
/// The scoring weights (`DESIGN.md` §5.5). `resolution_step` defaults to the
|
||||
@@ -117,10 +121,18 @@ impl PolicyInput {
|
||||
));
|
||||
}
|
||||
}
|
||||
for resolution in self.size_bands.keys() {
|
||||
for (resolution, band) in &self.size_bands {
|
||||
if arr_db::policy::resolution_value(resolution).is_none() {
|
||||
return Err(format!("size_bands: unknown resolution '{resolution}'"));
|
||||
}
|
||||
if band
|
||||
.ceiling_gib
|
||||
.is_some_and(|ceiling| ceiling < band.target_gib)
|
||||
{
|
||||
return Err(format!(
|
||||
"size_bands.{resolution}.ceiling_gib: must not be below target_gib"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for resolution in &self.resolution_pref {
|
||||
@@ -696,6 +708,19 @@ mod tests {
|
||||
}),
|
||||
"source_weights",
|
||||
),
|
||||
(
|
||||
with(&|payload| {
|
||||
payload["size_bands"] = serde_json::json!({
|
||||
"1080p": {
|
||||
"floor_gib": 3,
|
||||
"target_gib": 8,
|
||||
"penalty_points_per_gib_over": 60,
|
||||
"ceiling_gib": 6
|
||||
}
|
||||
});
|
||||
}),
|
||||
"ceiling_gib",
|
||||
),
|
||||
];
|
||||
|
||||
for (payload, field) in cases {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::score::{claimed_episode_count, score, RankKey};
|
||||
use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict};
|
||||
use arr_db::policy::language;
|
||||
use arr_db::{blacklist, Blacklist};
|
||||
@@ -482,7 +482,13 @@ pub async fn releases(
|
||||
))
|
||||
}
|
||||
};
|
||||
classified.sort_by_key(|release| (bucket(&release.verdict), -release.score));
|
||||
// The daemon's own order inside each bucket, so the deck's top eligible
|
||||
// row is the release an automatic grab would take.
|
||||
classified.sort_by(|left, right| {
|
||||
bucket(&left.verdict)
|
||||
.cmp(&bucket(&right.verdict))
|
||||
.then_with(|| rank_key(left).cmp(&rank_key(right)))
|
||||
});
|
||||
Ok(Json(classified))
|
||||
}
|
||||
|
||||
@@ -949,6 +955,14 @@ fn rule_name(rule: &Rule) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_key(release: &ClassifiedRelease) -> RankKey<'_> {
|
||||
RankKey {
|
||||
score: release.score,
|
||||
indexer_id: release.indexer_id,
|
||||
guid: &release.guid,
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket(verdict: &str) -> u8 {
|
||||
match verdict {
|
||||
"eligible" => 0,
|
||||
@@ -1661,6 +1675,7 @@ mod tests {
|
||||
floor_bytes: 8 << 30,
|
||||
target_bytes: 22 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -1669,6 +1684,7 @@ mod tests {
|
||||
floor_bytes: 3 << 30,
|
||||
target_bytes: 8 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
@@ -1694,6 +1710,7 @@ mod tests {
|
||||
floor_bytes: 8 << 30,
|
||||
target_bytes: 22 << 30,
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
)]),
|
||||
resolution_preference: vec![Resolution::R2160p],
|
||||
|
||||
@@ -212,7 +212,8 @@ pub struct HdrRules {
|
||||
|
||||
/// One resolution's size band (`DESIGN.md` §5.5). Below the floor is a hard
|
||||
/// filter; the target is where the score peaks; above it the penalty grows
|
||||
/// with every gibibyte.
|
||||
/// with every gibibyte, and above the ceiling the release is waived rather
|
||||
/// than eligible.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SizeBand {
|
||||
pub floor_bytes: u64,
|
||||
@@ -223,6 +224,11 @@ pub struct SizeBand {
|
||||
/// fractional type. Applied per byte over, so a partial gibibyte costs
|
||||
/// its fraction.
|
||||
pub penalty_points_per_gib_over: i32,
|
||||
/// Above this the release is bloated: automatic selection never takes
|
||||
/// it, a manual grab still can. `None` is no ceiling. It is what keeps
|
||||
/// strict resolution tiers from preferring a 60 GB 4K remux over every
|
||||
/// good 1080p (§5.4).
|
||||
pub ceiling_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
||||
@@ -256,6 +256,18 @@ impl PolicyRule for SizeRule {
|
||||
else {
|
||||
return RuleEvaluation::Unknown(RuleKind::Size);
|
||||
};
|
||||
// Bloat is never a reason to throw a release away, only a reason not
|
||||
// to pick it unattended: the ceiling waives in both phases.
|
||||
if crate::score::is_above_ceiling(
|
||||
context.policy,
|
||||
resolution,
|
||||
size,
|
||||
context.episode_count,
|
||||
context.runtime_minutes,
|
||||
) == Some(true)
|
||||
{
|
||||
return RuleEvaluation::SoftFail(Rule::Size);
|
||||
}
|
||||
match crate::score::is_below_floor(
|
||||
context.policy,
|
||||
resolution,
|
||||
@@ -700,6 +712,7 @@ mod tests {
|
||||
floor_bytes: 2 << 30,
|
||||
target_bytes: 4 << 30,
|
||||
penalty_points_per_gib_over: 600,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
)]),
|
||||
resolution_preference: vec![Resolution::R1080p],
|
||||
@@ -707,6 +720,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn above_the_ceiling_is_waived_and_a_pack_is_measured_per_episode() {
|
||||
let mut policy = banded_policy();
|
||||
if let Some(band) = policy.size_bands.get_mut(&Resolution::R1080p) {
|
||||
band.ceiling_bytes = Some(10 << 30);
|
||||
}
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::Remux));
|
||||
let verdict_at = |size: u64, episodes: u32| {
|
||||
evaluate(
|
||||
&policy,
|
||||
&TitleOverrides::default(),
|
||||
&en(),
|
||||
Candidate::PreGrab(&claims),
|
||||
Some(size),
|
||||
episodes,
|
||||
0,
|
||||
)
|
||||
.verdict
|
||||
};
|
||||
|
||||
assert_eq!(verdict_at(12 << 30, 1), Verdict::Waived(Rule::Size));
|
||||
assert_eq!(verdict_at(10 << 30, 1), Verdict::Eligible);
|
||||
// Ten episodes at 3 GiB each sit well under a 10 GiB ceiling.
|
||||
assert_eq!(verdict_at(30 << 30, 10), Verdict::Eligible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_below_floor_release_is_rejected_without_the_override() {
|
||||
let claims = claims(Some(ClaimedResolution::P1080), Some(ClaimedSource::WebDl));
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! Size is scored against the band for the release's own resolution, so it
|
||||
//! says nothing about how resolutions rank against each other. That is the
|
||||
//! resolution-rank term: the policy's `resolution_preference` is ordered, and
|
||||
//! each step up it is worth a fixed number of points.
|
||||
//! each step up it is worth more than every other term together, so the list
|
||||
//! reads as tiers — 4K if there is any, otherwise the best 1080p.
|
||||
//!
|
||||
//! Below the floor is a hard filter rather than a low score, and lives in
|
||||
//! [`crate::policy::SizeRule`]: unbounded "smaller is better" would otherwise
|
||||
@@ -50,12 +51,11 @@ pub struct ScoreWeights {
|
||||
/// that already decides eligibility also decides how resolutions rank
|
||||
/// against each other.
|
||||
///
|
||||
/// Sized against the size term deliberately. With the seeded bands — 4K
|
||||
/// target 22 GiB, 60 points per gibibyte over — 300 points is five
|
||||
/// gibibytes of overshoot: a 4K release up to 27 GiB outranks an
|
||||
/// at-target 1080p, and a bloated 4K past that does not. Below its
|
||||
/// target a 4K has to be within about four gibibytes of it to win, which
|
||||
/// keeps a mud-quality 8 GiB 4K from beating a good 1080p.
|
||||
/// Sized to dwarf every other term, so the steps are tiers: any eligible
|
||||
/// release of a preferred resolution outranks every release of the next
|
||||
/// one, and the size, source and seeder terms only order releases inside
|
||||
/// a tier (§5.4). The size band's floor and ceiling are what keep a mud
|
||||
/// encode or a bloated remux out of the top tier, not this weight.
|
||||
pub resolution_step: i32,
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Default for ScoreWeights {
|
||||
size_at_target: 1000,
|
||||
source_tier: 25,
|
||||
seeder_doubling: 8,
|
||||
resolution_step: 300,
|
||||
resolution_step: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,35 @@ pub fn score(
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a candidate sits in the one order every selection uses: targeted
|
||||
/// movie search, TV search, RSS and the manual-search deck. Higher score
|
||||
/// first, then lower indexer id, then guid, so the same candidates always
|
||||
/// produce the same winner whichever trigger looked at them.
|
||||
///
|
||||
/// Sorting ascending by this key puts the best candidate first.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RankKey<'a> {
|
||||
pub score: i64,
|
||||
pub indexer_id: i64,
|
||||
pub guid: &'a str,
|
||||
}
|
||||
|
||||
impl Ord for RankKey<'_> {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
other
|
||||
.score
|
||||
.cmp(&self.score)
|
||||
.then_with(|| self.indexer_id.cmp(&other.indexer_id))
|
||||
.then_with(|| self.guid.cmp(other.guid))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for RankKey<'_> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a stored release from the claims parsed out of its name.
|
||||
///
|
||||
/// Pre-grab, the name is all there is (`DESIGN.md` §5.6).
|
||||
@@ -185,6 +214,25 @@ pub fn is_below_floor(
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a size sits above its band's ceiling: bloated, so waived rather
|
||||
/// than eligible (`DESIGN.md` §5.4). Measured and scaled exactly as
|
||||
/// [`is_below_floor`] measures the floor.
|
||||
///
|
||||
/// `None` when the policy carries no band for that resolution, or the band
|
||||
/// carries no ceiling.
|
||||
#[must_use]
|
||||
pub fn is_above_ceiling(
|
||||
policy: &Policy,
|
||||
resolution: Resolution,
|
||||
size_bytes: u64,
|
||||
episode_count: u32,
|
||||
runtime_minutes: u32,
|
||||
) -> Option<bool> {
|
||||
let band = scaled_band(policy.size_bands.get(&resolution)?, runtime_minutes);
|
||||
band.ceiling_bytes
|
||||
.map(|ceiling| per_episode_size(size_bytes, episode_count) > ceiling)
|
||||
}
|
||||
|
||||
/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor
|
||||
/// behind [`score`] and [`is_below_floor`].
|
||||
///
|
||||
@@ -230,6 +278,9 @@ fn scaled_band(band: &SizeBand, runtime_minutes: u32) -> 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,
|
||||
ceiling_bytes: band
|
||||
.ceiling_bytes
|
||||
.map(|ceiling| scale_by_runtime(ceiling, runtime_minutes)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +385,7 @@ mod tests {
|
||||
floor_bytes: gib(8),
|
||||
target_bytes: gib(22),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -342,6 +394,7 @@ mod tests {
|
||||
floor_bytes: gib(3),
|
||||
target_bytes: gib(8),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
@@ -456,7 +509,7 @@ mod tests {
|
||||
|
||||
// A bad score, but a score: nothing filters it out, so a selection
|
||||
// with one candidate takes it.
|
||||
assert!(scored(ClaimedSource::Remux, gib(60), 12).total < 0);
|
||||
assert!(scored(ClaimedSource::Remux, gib(60), 12).size < 0);
|
||||
assert_eq!(evaluation.verdict, Verdict::Eligible);
|
||||
}
|
||||
|
||||
@@ -548,6 +601,7 @@ mod tests {
|
||||
floor_bytes: gib(10),
|
||||
target_bytes: gib(10),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
);
|
||||
let claims = claims(ClaimedSource::WebDl);
|
||||
@@ -569,12 +623,16 @@ mod tests {
|
||||
assert!(uhd.total > hd.total);
|
||||
}
|
||||
|
||||
/// §5.4: 4K if there is any. Inside the band a 4K outranks every 1080p,
|
||||
/// however far over target it is and however good the 1080p.
|
||||
#[test]
|
||||
fn a_4k_far_over_target_still_loses_to_an_at_target_1080p() {
|
||||
let bloated = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(40), 20);
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::WebDl, gib(8), 20);
|
||||
fn a_4k_far_over_target_still_outranks_an_at_target_1080p() {
|
||||
let bloated = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(40), 2);
|
||||
let mud = scored_at(ClaimedResolution::P2160, ClaimedSource::WebDl, gib(9), 2);
|
||||
let hd = scored_at(ClaimedResolution::P1080, ClaimedSource::Remux, gib(8), 4096);
|
||||
|
||||
assert!(bloated.total < hd.total);
|
||||
assert!(bloated.total > hd.total);
|
||||
assert!(mud.total > hd.total);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -644,11 +702,11 @@ mod tests {
|
||||
assert_eq!(
|
||||
at_target,
|
||||
Score {
|
||||
total: 1398,
|
||||
total: 11098,
|
||||
size: 1000,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
resolution: 10_000,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -656,11 +714,11 @@ mod tests {
|
||||
assert_eq!(
|
||||
over_target,
|
||||
Score {
|
||||
total: 918,
|
||||
total: 10618,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
resolution: 10_000,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -774,6 +832,7 @@ mod tests {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
);
|
||||
let episodes = 10;
|
||||
@@ -802,11 +861,11 @@ mod tests {
|
||||
assert_eq!(
|
||||
missing,
|
||||
Score {
|
||||
total: 918,
|
||||
total: 10618,
|
||||
size: 520,
|
||||
source: 50,
|
||||
seeders: 48,
|
||||
resolution: 300,
|
||||
resolution: 10_000,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -881,6 +940,7 @@ mod tests {
|
||||
floor_bytes: GIB / 4,
|
||||
target_bytes: gib(2),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -889,6 +949,7 @@ mod tests {
|
||||
floor_bytes: gib(1),
|
||||
target_bytes: gib(6),
|
||||
penalty_points_per_gib_over: 60,
|
||||
ceiling_bytes: None,
|
||||
},
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arr_core::policy::{evaluate, Candidate};
|
||||
use arr_core::score::{claimed_episode_count, score};
|
||||
use arr_core::score::{claimed_episode_count, score, RankKey};
|
||||
use arr_core::{Language, Policy, TitleOverrides, Verdict};
|
||||
use arr_db::{blacklist, Blacklist, Db, MoviePolicy};
|
||||
use arr_dl::{AddTorrent, QbitClient, TorrentSource};
|
||||
@@ -262,13 +262,7 @@ impl GrabAction {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
candidates.sort_by(|left, right| {
|
||||
right
|
||||
.score
|
||||
.cmp(&left.score)
|
||||
.then_with(|| left.indexer_id.cmp(&right.indexer_id))
|
||||
.then_with(|| left.guid.cmp(&right.guid))
|
||||
});
|
||||
candidates.sort_by(|left, right| left.rank_key().cmp(&right.rank_key()));
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
@@ -885,6 +879,16 @@ pub(crate) struct Eligible {
|
||||
pub(crate) score: i64,
|
||||
}
|
||||
|
||||
impl Eligible {
|
||||
pub(crate) fn rank_key(&self) -> RankKey<'_> {
|
||||
RankKey {
|
||||
score: self.score,
|
||||
indexer_id: self.indexer_id,
|
||||
guid: &self.guid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The gap, straight out of the domain rows (§8).
|
||||
///
|
||||
/// A title with an unfinished grab is not a gap — that is what stops a
|
||||
@@ -1648,7 +1652,7 @@ mod tests {
|
||||
<item>
|
||||
<title>Dune.Part.Two.2024.2160p.REMUX</title>
|
||||
<guid>huge</guid><link>https://tracker/huge.torrent</link>
|
||||
<size>64424509440</size>
|
||||
<size>42949672960</size>
|
||||
<torznab:attr xmlns:torznab="http://torznab.com/schemas/2015/feed" name="seeders" value="12"/>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
@@ -425,12 +425,7 @@ struct Grabbable {
|
||||
/// Better score first, then the tiebreaks targeted search sorts by, so both
|
||||
/// triggers pick the same release out of the same candidates.
|
||||
fn beats(candidate: &Eligible, incumbent: &Eligible) -> bool {
|
||||
candidate
|
||||
.score
|
||||
.cmp(&incumbent.score)
|
||||
.then_with(|| incumbent.indexer_id.cmp(&candidate.indexer_id))
|
||||
.then_with(|| incumbent.guid.cmp(&candidate.guid))
|
||||
== std::cmp::Ordering::Greater
|
||||
candidate.rank_key() < incumbent.rank_key()
|
||||
}
|
||||
|
||||
/// Every wanted title with neither a file nor a grab in flight.
|
||||
@@ -736,7 +731,7 @@ mod tests {
|
||||
<item>
|
||||
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP</title>
|
||||
<guid>tv-pack</guid><link>https://indexer.invalid/download/tv-pack</link>
|
||||
<enclosure url="https://indexer.invalid/download/tv-pack" length="85899345920"
|
||||
<enclosure url="https://indexer.invalid/download/tv-pack" length="10737418240"
|
||||
type="application/x-bittorrent"/>
|
||||
<torznab:attr name="seeders" value="120"/>
|
||||
</item>
|
||||
|
||||
@@ -1186,13 +1186,7 @@ fn best_candidate<'a>(
|
||||
let mut eligible: Vec<&Eligible> = candidates
|
||||
.filter_map(|candidate| candidate.eligible.as_ref())
|
||||
.collect();
|
||||
eligible.sort_by(|left, right| {
|
||||
right
|
||||
.score
|
||||
.cmp(&left.score)
|
||||
.then_with(|| left.indexer_id.cmp(&right.indexer_id))
|
||||
.then_with(|| left.guid.cmp(&right.guid))
|
||||
});
|
||||
eligible.sort_by(|left, right| left.rank_key().cmp(&right.rank_key()));
|
||||
eligible
|
||||
.into_iter()
|
||||
.find(|candidate| !blacklist.blocks_candidate(&candidate.name, &candidate.download_url))
|
||||
@@ -1239,7 +1233,7 @@ mod tests {
|
||||
<item>
|
||||
<title>Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos</title>
|
||||
<guid>pack</guid><link>https://tracker/pack.torrent</link>
|
||||
<size>85899345920</size>
|
||||
<size>32212254720</size>
|
||||
<torznab:attr xmlns:torznab="http://torznab.com/schemas/2015/feed" name="seeders" value="120"/>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
-- §5.4: 4K if there is any, otherwise a good 1080p. The resolution step was
|
||||
-- 300 points, small enough that an on-target 1080p outranked a 4K a few
|
||||
-- gibibytes off its own target. 10 000 dwarfs every other term, so the
|
||||
-- preference list reads as tiers and the size, source and seeder terms only
|
||||
-- order releases inside one.
|
||||
--
|
||||
-- With strict tiers a bloated 4K remux would beat every 1080p, so each band
|
||||
-- gains a ceiling. Above it a release is waived: never picked automatically,
|
||||
-- still a manual grab. Twice the target for 4K and two and a half times for
|
||||
-- 1080p, which on the seeded bands is 44 GiB and 20 GiB for movies, 12 GiB
|
||||
-- and 5 GiB per episode for TV. A band that already carries a ceiling keeps
|
||||
-- it.
|
||||
UPDATE policies
|
||||
SET score_weights = json_set(score_weights, '$.resolution_step', 10000),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now');
|
||||
|
||||
UPDATE policies
|
||||
SET size_bands = json_set(
|
||||
size_bands,
|
||||
'$."2160p".ceiling_gib',
|
||||
json_extract(size_bands, '$."2160p".target_gib') * 2
|
||||
),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE json_extract(size_bands, '$."2160p"') IS NOT NULL
|
||||
AND json_extract(size_bands, '$."2160p".ceiling_gib') IS NULL;
|
||||
|
||||
UPDATE policies
|
||||
SET size_bands = json_set(
|
||||
size_bands,
|
||||
'$."1080p".ceiling_gib',
|
||||
CAST(json_extract(size_bands, '$."1080p".target_gib') * 5 / 2 AS INTEGER)
|
||||
),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE json_extract(size_bands, '$."1080p"') IS NOT NULL
|
||||
AND json_extract(size_bands, '$."1080p".ceiling_gib') IS NULL;
|
||||
@@ -111,6 +111,7 @@ impl PolicyColumns {
|
||||
floor_bytes: gib(band.floor_gib),
|
||||
target_bytes: gib(band.target_gib),
|
||||
penalty_points_per_gib_over: band.penalty_points_per_gib_over,
|
||||
ceiling_bytes: band.ceiling_gib.map(gib),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -474,6 +475,10 @@ struct SizeBandJson {
|
||||
floor_gib: u64,
|
||||
target_gib: u64,
|
||||
penalty_points_per_gib_over: i32,
|
||||
/// Added in 0035. Absent means no ceiling, which is what every band
|
||||
/// carried before it.
|
||||
#[serde(default)]
|
||||
ceiling_gib: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -575,6 +580,7 @@ mod tests {
|
||||
assert_eq!(band.floor_bytes, 8 << 30);
|
||||
assert_eq!(band.target_bytes, 22 << 30);
|
||||
assert_eq!(band.penalty_points_per_gib_over, 60);
|
||||
assert_eq!(band.ceiling_bytes, Some(44 << 30));
|
||||
assert_eq!(loaded.policy.score_weights.size_at_target, 500);
|
||||
assert_eq!(loaded.policy.score_weights.source_tier, 10);
|
||||
assert_eq!(loaded.policy.score_weights.seeder_doubling, 1);
|
||||
@@ -594,7 +600,8 @@ mod tests {
|
||||
.policy
|
||||
.score_weights
|
||||
.resolution_step;
|
||||
assert_eq!(seeded_step, 300);
|
||||
// 0015 seeded 300; 0035 raised it to a tier (§5.4).
|
||||
assert_eq!(seeded_step, 10_000);
|
||||
|
||||
sqlx::query(
|
||||
r#"UPDATE policies SET score_weights = '{"size_at_target":500,"source_tier":10,"seeder_doubling":1}'"#,
|
||||
|
||||
+21
-6
@@ -17,7 +17,12 @@ export interface PolicyDoc {
|
||||
hdr_rules: { dv_profile_reject: string[] };
|
||||
size_bands: Record<
|
||||
string,
|
||||
{ floor_gib: number; target_gib: number; penalty_points_per_gib_over: number }
|
||||
{
|
||||
floor_gib: number;
|
||||
target_gib: number;
|
||||
penalty_points_per_gib_over: number;
|
||||
ceiling_gib?: number;
|
||||
}
|
||||
>;
|
||||
resolution_pref: string[];
|
||||
source_weights: Record<string, number>;
|
||||
@@ -501,7 +506,7 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
size_at_target: 1000,
|
||||
source_tier: 25,
|
||||
seeder_doubling: 8,
|
||||
resolution_step: 300,
|
||||
resolution_step: 10000,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -557,7 +562,8 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
profilesInput = profiles.querySelector("input") ?? document.createElement("input");
|
||||
grid.append(profiles);
|
||||
|
||||
// one line per resolution: floor, target, penalty — numbers stay numbers
|
||||
// one line per resolution: floor, target, penalty, ceiling — numbers stay
|
||||
// numbers, and an empty ceiling is no ceiling
|
||||
for (const resolution of RESOLUTIONS) {
|
||||
const band = current.size_bands[resolution];
|
||||
const line = el("div", "band-line");
|
||||
@@ -566,10 +572,16 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
const floor = numberControl(band?.floor_gib ?? 0);
|
||||
const target = numberControl(band?.target_gib ?? 0);
|
||||
const penalty = numberControl(band?.penalty_points_per_gib_over ?? 0);
|
||||
const ceiling = numberControl(band?.ceiling_gib ?? 0);
|
||||
if (band?.ceiling_gib === undefined) {
|
||||
ceiling.value = "";
|
||||
}
|
||||
ceiling.placeholder = "∞";
|
||||
floor.setAttribute("aria-label", `${resolution} floor GiB`);
|
||||
target.setAttribute("aria-label", `${resolution} target GiB`);
|
||||
penalty.setAttribute("aria-label", `${resolution} penalty points per GiB over`);
|
||||
for (const input of [floor, target, penalty]) {
|
||||
ceiling.setAttribute("aria-label", `${resolution} ceiling GiB, empty for none`);
|
||||
for (const input of [floor, target, penalty, ceiling]) {
|
||||
input.classList.add("band-num");
|
||||
line.append(input);
|
||||
}
|
||||
@@ -630,14 +642,17 @@ export function settingsMain(views: { hide: () => void }[], goHome: () => void):
|
||||
|
||||
const sizeBands: PolicyDoc["size_bands"] = {};
|
||||
for (const line of panel.querySelectorAll<HTMLDivElement>(".band-line")) {
|
||||
const [floor, target, penalty] = [...line.querySelectorAll<HTMLInputElement>("input")];
|
||||
if (!floor || !target || !penalty) {
|
||||
const [floor, target, penalty, ceiling] = [
|
||||
...line.querySelectorAll<HTMLInputElement>("input"),
|
||||
];
|
||||
if (!floor || !target || !penalty || !ceiling) {
|
||||
continue;
|
||||
}
|
||||
sizeBands[line.dataset.resolution ?? ""] = {
|
||||
floor_gib: Number(floor.value),
|
||||
target_gib: Number(target.value),
|
||||
penalty_points_per_gib_over: Number(penalty.value),
|
||||
...(ceiling.value === "" ? {} : { ceiling_gib: Number(ceiling.value) }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -2462,8 +2462,12 @@ select.form-input {
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
/* four numbers and a label must share a phone's width, so the inputs shrink
|
||||
rather than push the line past the gutter */
|
||||
.band-num {
|
||||
width: 4.5rem;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: 4.5rem;
|
||||
}
|
||||
|
||||
.subtitle-panel .field-label {
|
||||
|
||||
Reference in New Issue
Block a user