1e03873209
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
247 lines
8.7 KiB
Rust
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);
|
|
}
|
|
}
|