feat: season-pack vs per-episode grab selection (#94)
ci / web (push) Successful in 24s
e2e / e2e (push) Successful in 54s
ci / rust (push) Successful in 2m11s

This commit was merged in pull request #94.
This commit is contained in:
2026-08-23 01:40:53 +01:00
parent 35391f02fc
commit 9f812fac11
28 changed files with 2855 additions and 143 deletions
+123
View File
@@ -0,0 +1,123 @@
//! 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 instead of being blacklisted outright.
//!
//! 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 season-pack grab for this season already hard-failed.
pub pack_hard_failed: 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_hard_failed {
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_hard_failed: 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_hard_failed_pack_falls_back_to_per_episode() {
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut facts = facts(&aired);
facts.pack_hard_failed = 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);
}
}