//! 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], 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 { 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]) -> 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); } }