+133
-12
@@ -10,7 +10,7 @@
|
||||
//! 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::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// How a season's missing wanted episodes should be grabbed next.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -37,24 +37,75 @@ pub struct SeasonGrabFacts<'a> {
|
||||
pub pack_backoff_active: bool,
|
||||
}
|
||||
|
||||
/// Picks the grab mode for one season.
|
||||
/// 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_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
||||
let fully_released = !facts.air_dates.is_empty()
|
||||
&& facts
|
||||
.air_dates
|
||||
.iter()
|
||||
.all(|date| date.is_some_and(|date| date <= facts.now));
|
||||
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
|
||||
}
|
||||
|
||||
if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active {
|
||||
SeasonGrabMode::SeasonPack
|
||||
} else {
|
||||
SeasonGrabMode::PerEpisode
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +173,74 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ pub mod status;
|
||||
pub mod tracking;
|
||||
|
||||
pub use arr_parse::NameClaims as ParsedRelease;
|
||||
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode};
|
||||
pub use grabbing::{
|
||||
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts,
|
||||
SeasonGrabMode,
|
||||
};
|
||||
pub use matching::{
|
||||
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
|
||||
WantedEpisode, WantedMovie,
|
||||
|
||||
Reference in New Issue
Block a user