From cd9e8a891c6a65cbc78419324d5cda1f00357b6a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 7 Nov 2026 18:02:12 +0000 Subject: [PATCH] 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) --- DESIGN.md | 47 ++++++--- crates/arr-api/src/policies.rs | 27 ++++- crates/arr-api/src/search.rs | 21 +++- crates/arr-core/src/lib.rs | 8 +- crates/arr-core/src/policy.rs | 39 ++++++++ crates/arr-core/src/score.rs | 99 +++++++++++++++---- crates/arr-daemon/src/grab.rs | 22 +++-- crates/arr-daemon/src/rss.rs | 9 +- crates/arr-daemon/src/tv_grab.rs | 10 +- .../migrations/0035_resolution_tiers.sql | 35 +++++++ crates/arr-db/src/policy.rs | 9 +- web/src/settings.ts | 27 +++-- web/src/style.css | 6 +- 13 files changed, 290 insertions(+), 69 deletions(-) create mode 100644 crates/arr-db/migrations/0035_resolution_tiers.sql diff --git a/DESIGN.md b/DESIGN.md index 83c2268..a35e8d1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/crates/arr-api/src/policies.rs b/crates/arr-api/src/policies.rs index 6ebb77c..ebb75e3 100644 --- a/crates/arr-api/src/policies.rs +++ b/crates/arr-api/src/policies.rs @@ -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, } /// 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 { diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index c8bcc68..065dbad 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -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], diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index fbd04b8..f6b20b6 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -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, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index 3493e69..bfc2836 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -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)); diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index d569358..6af5acf 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -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 { + 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 { + 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, }, ), ]), diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 8401e9f..e47fe2a 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -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 { Dune.Part.Two.2024.2160p.REMUX hugehttps://tracker/huge.torrent - 64424509440 + 42949672960 diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index bdd076b..000eea6 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -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 { Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos.H.265-GROUP tv-packhttps://indexer.invalid/download/tv-pack - diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 1c4a863..c26484e 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -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 { Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos packhttps://tracker/pack.torrent - 85899345920 + 32212254720 diff --git a/crates/arr-db/migrations/0035_resolution_tiers.sql b/crates/arr-db/migrations/0035_resolution_tiers.sql new file mode 100644 index 0000000..6d22f28 --- /dev/null +++ b/crates/arr-db/migrations/0035_resolution_tiers.sql @@ -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; diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index e62ad49..c1a3c62 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -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, } #[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}'"#, diff --git a/web/src/settings.ts b/web/src/settings.ts index 293e735..b768749 100644 --- a/web/src/settings.ts +++ b/web/src/settings.ts @@ -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; @@ -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(".band-line")) { - const [floor, target, penalty] = [...line.querySelectorAll("input")]; - if (!floor || !target || !penalty) { + const [floor, target, penalty, ceiling] = [ + ...line.querySelectorAll("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) }), }; } diff --git a/web/src/style.css b/web/src/style.css index b702ef7..0e482c6 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -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 {