Files
arr/crates/arr-core/src/grabbing.rs
T
Miguel Palhas 1e03873209 feat(web): name the season deck's real state
An empty season deck was three truths wearing one message, and the one
it chose to blame was wrong: a season on the per-episode lane sat on
"sweeping indexers…" for the full wait and then blamed a backoff for a
pack search that was never going to run.

`GET /api/series/{id}/seasons/{n}/pack-state` says which lane the
season takes and why, from `season_grab_reason` in arr-core, plus the
failed-pack tally and when #181's window reopens. Seasons gain
`last_pack_search_at`, written only by a season-scoped sweep, so a
pack search that ran and found nothing is a settled answer rather than
a pending one.

The deck then says the true thing in each case, and a season held off
the pack lane by a failure offers the retry that waives its window.

Refs #182
2026-08-24 20:14:48 +01:00

247 lines
8.7 KiB
Rust

//! Season-pack versus per-episode grab selection (`DESIGN.md` §13 phase 6).
//!
//! The operator's rule: season packs only when the season is fully released;
//! while a season is airing, grab per episode. A completed season with no
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
//! encode. A pack that hard-failed must not cost the whole season, so the
//! season falls back to per-episode while the failure's §6.2 backoff window
//! is open, and retries the pack once it elapses — quiet, never off.
//!
//! Re-grabbing a pack once an airing season completes is deliberately not
//! done (§14): a season with any episode already on disk grabs per episode.
use std::time::{Duration, SystemTime};
/// How a season's missing wanted episodes should be grabbed next.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SeasonGrabMode {
/// One season-pack torrent for the whole season.
SeasonPack,
/// One grab per aired wanted episode.
PerEpisode,
}
/// Everything the season-pack decision depends on.
#[derive(Clone, Copy, Debug)]
pub struct SeasonGrabFacts<'a> {
/// One entry per episode the season is known to hold, aired or not.
/// `None` is an announced episode with no date yet.
pub air_dates: &'a [Option<SystemTime>],
pub now: SystemTime,
/// Whether any episode of the season already has a file (§14: nothing
/// re-grabs a pack over episodes on disk, and a pack must not re-import
/// what exists).
pub any_episode_on_disk: bool,
/// Whether a failed season-pack grab still holds the season off the
/// pack lane — true only while the §6.2 backoff window is open.
pub pack_backoff_active: bool,
}
/// Why a season is not on the season-pack lane.
///
/// The season release deck (§9.3) is empty for a season on the per-episode
/// lane and stays empty however long it waits, so it has to name which of
/// these it is rather than blaming a sweep that is not coming (#182).
/// Ordered by how fundamental the answer is: an unaired episode outranks a
/// failed pack, because clearing the failure would still not earn a pack.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PerEpisodeReason {
/// No episodes are known for the season, so there is nothing to pack.
NoEpisodes,
/// An episode has not aired, or carries no air date at all.
StillAiring,
/// §14: a pack would re-import episodes that are already on disk.
EpisodesOnDisk,
/// §6.2: a failed pack grab's backoff window is still open.
PackBackoff,
}
/// Why one season takes the per-episode lane, or `None` when it takes the
/// pack lane.
///
/// A season is fully released only when every known episode has an air date
/// in the past. An episode with no date could still be unaired, and grabbing
/// a "complete" pack of a season that is not complete costs a whole torrent
/// of the wrong thing — so an undated episode keeps the season per-episode.
#[must_use]
pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option<PerEpisodeReason> {
if facts.air_dates.is_empty() {
return Some(PerEpisodeReason::NoEpisodes);
}
if !facts
.air_dates
.iter()
.all(|date| date.is_some_and(|date| date <= facts.now))
{
return Some(PerEpisodeReason::StillAiring);
}
if facts.any_episode_on_disk {
return Some(PerEpisodeReason::EpisodesOnDisk);
}
if facts.pack_backoff_active {
return Some(PerEpisodeReason::PackBackoff);
}
None
}
/// Picks the grab mode for one season.
#[must_use]
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
match season_grab_reason(facts) {
None => SeasonGrabMode::SeasonPack,
Some(_) => SeasonGrabMode::PerEpisode,
}
}
/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed
/// by how many attempts have already been spent.
///
/// The pack lane counts a season's failed pack grabs as its attempts, so the
/// deck can say when the lane reopens rather than only that it is shut.
#[must_use]
pub fn search_backoff(attempts: i64) -> Duration {
match attempts {
..=1 => Duration::from_hours(1),
2 => Duration::from_hours(6),
3 => Duration::from_hours(24),
4 => Duration::from_hours(72),
_ => Duration::from_hours(24 * 7),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
const DAY: Duration = Duration::from_hours(24);
fn facts(air_dates: &[Option<SystemTime>]) -> SeasonGrabFacts<'_> {
SeasonGrabFacts {
air_dates,
now: SystemTime::UNIX_EPOCH + 100 * DAY,
any_episode_on_disk: false,
pack_backoff_active: false,
}
}
#[test]
fn a_fully_released_season_with_nothing_on_disk_takes_the_pack() {
let aired = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 17 * DAY),
];
assert_eq!(season_grab_mode(&facts(&aired)), SeasonGrabMode::SeasonPack);
}
#[test]
fn an_airing_season_grabs_per_episode() {
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
assert_eq!(
season_grab_mode(&facts(&airing)),
SeasonGrabMode::PerEpisode
);
}
#[test]
fn an_undated_episode_keeps_the_season_per_episode() {
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
assert_eq!(
season_grab_mode(&facts(&undated)),
SeasonGrabMode::PerEpisode
);
assert_eq!(season_grab_mode(&facts(&[])), SeasonGrabMode::PerEpisode);
}
#[test]
fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.pack_backoff_active = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
/// §14: episodes already on disk are never re-grabbed as part of a pack.
#[test]
fn a_season_with_an_episode_on_disk_grabs_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.any_episode_on_disk = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
}
/// #182: the deck says which of the four it is, not just "not a pack".
#[test]
fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() {
assert_eq!(
season_grab_reason(&facts(&[])),
Some(PerEpisodeReason::NoEpisodes)
);
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
assert_eq!(
season_grab_reason(&facts(&airing)),
Some(PerEpisodeReason::StillAiring)
);
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
assert_eq!(
season_grab_reason(&facts(&undated)),
Some(PerEpisodeReason::StillAiring)
);
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut on_disk = facts(&aired);
on_disk.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&on_disk),
Some(PerEpisodeReason::EpisodesOnDisk)
);
let mut backoff = facts(&aired);
backoff.pack_backoff_active = true;
assert_eq!(
season_grab_reason(&backoff),
Some(PerEpisodeReason::PackBackoff)
);
assert_eq!(season_grab_reason(&facts(&aired)), None);
}
/// A failed pack is not the headline when the season could not have had
/// a pack anyway — clearing it would change nothing.
#[test]
fn an_unaired_episode_outranks_a_failed_pack() {
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
let mut facts = facts(&airing);
facts.pack_backoff_active = true;
facts.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&facts),
Some(PerEpisodeReason::StillAiring)
);
}
/// §6.2's curve, shared by the pack lane so the deck can say when it
/// reopens rather than only that it is shut.
#[test]
fn the_backoff_curve_climbs_and_caps_at_a_week() {
assert_eq!(search_backoff(0), Duration::from_hours(1));
assert_eq!(search_backoff(1), Duration::from_hours(1));
assert_eq!(search_backoff(2), Duration::from_hours(6));
assert_eq!(search_backoff(3), DAY);
assert_eq!(search_backoff(4), 3 * DAY);
assert_eq!(search_backoff(5), 7 * DAY);
assert_eq!(search_backoff(50), 7 * DAY);
}
}