feat(web): name the season deck's real state
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
This commit is contained in:
+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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user