A failed season-pack grab held the season off the pack lane forever: pack_hard_failed was a bare EXISTS over failed grabs, so one bad torrent disabled pack search for good, against §6.2's "it never gives up entirely, it goes quiet". The guard now rides the shared backoff curve (backoff_elapsed, 1h → 6h → 1d → 3d, capped 7d), counting failed pack grabs as attempts and anchoring on the latest one's grabbed_at. Both the targeted and RSS lanes agree. A manual season search waives the window outright — the season deck is §6.2's escape hatch. Closes #181. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.5 KiB
Rust
126 lines
4.5 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::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,
|
|
}
|
|
|
|
/// Picks the grab mode for one season.
|
|
///
|
|
/// 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));
|
|
|
|
if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active {
|
|
SeasonGrabMode::SeasonPack
|
|
} else {
|
|
SeasonGrabMode::PerEpisode
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|