derive_series_status identified specials by looking the episode's season up in a parallel seasons slice, so a caller passing an incomplete slice silently reverted to pre-#118 behaviour. The season number now rides on each episode and the slice is gone.
246 lines
7.6 KiB
Rust
246 lines
7.6 KiB
Rust
use std::time::{Duration, SystemTime};
|
|
|
|
use crate::{Episode, MediaState, Series};
|
|
|
|
const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24);
|
|
|
|
/// A series' current state, derived from upstream metadata and local files.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum SeriesStatus {
|
|
Airing,
|
|
Incomplete,
|
|
Waiting,
|
|
Complete,
|
|
Ended,
|
|
}
|
|
|
|
/// Derives a series' status at `now` without persisting lifecycle intent.
|
|
///
|
|
/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes with
|
|
/// `season_number` 0 are ignored, so a manually wanted special cannot pin
|
|
/// the series at `incomplete` or hold back `ended`. The season number rides
|
|
/// on each episode, so there is no second slice a caller can forget (#131).
|
|
#[must_use]
|
|
pub fn derive_series_status(
|
|
series: &Series,
|
|
episodes: &[Episode],
|
|
now: SystemTime,
|
|
) -> SeriesStatus {
|
|
let wanted = episodes
|
|
.iter()
|
|
.filter(|episode| episode.wanted && episode.season_number != 0);
|
|
let complete = wanted
|
|
.clone()
|
|
.all(|episode| episode.state == MediaState::Available);
|
|
|
|
if complete {
|
|
return if series.upstream_ended {
|
|
SeriesStatus::Ended
|
|
} else if series.auto_track {
|
|
SeriesStatus::Waiting
|
|
} else {
|
|
SeriesStatus::Complete
|
|
};
|
|
}
|
|
|
|
if wanted.clone().any(|episode| {
|
|
episode
|
|
.air_date
|
|
.is_some_and(|air_date| is_airing(air_date, now))
|
|
}) {
|
|
return SeriesStatus::Airing;
|
|
}
|
|
|
|
let window_end = now.checked_add(AIRING_WINDOW);
|
|
let only_future_or_undated_missing = wanted
|
|
.filter(|episode| episode.state != MediaState::Available)
|
|
.all(|episode| match (episode.air_date, window_end) {
|
|
(Some(air_date), Some(end)) => air_date > end,
|
|
(Some(_), None) => false,
|
|
(None, _) => true,
|
|
});
|
|
|
|
if only_future_or_undated_missing {
|
|
SeriesStatus::Waiting
|
|
} else {
|
|
SeriesStatus::Incomplete
|
|
}
|
|
}
|
|
|
|
fn is_airing(air_date: SystemTime, now: SystemTime) -> bool {
|
|
air_date
|
|
.duration_since(now)
|
|
.or_else(|_| now.duration_since(air_date))
|
|
.is_ok_and(|distance| distance <= AIRING_WINDOW)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{EpisodeId, Language, RootId, SeasonId, SeriesId, TitleOverrides};
|
|
|
|
const DAY: Duration = Duration::from_hours(24);
|
|
|
|
fn series(auto_track: bool, upstream_ended: bool) -> Series {
|
|
Series {
|
|
id: SeriesId(1),
|
|
tmdb_id: 82_728,
|
|
title: "Bluey".into(),
|
|
year: 2018,
|
|
original_language: Language::Other("en".into()),
|
|
root_id: RootId(1),
|
|
auto_track,
|
|
overrides: TitleOverrides::default(),
|
|
upstream_ended,
|
|
blocked: false,
|
|
}
|
|
}
|
|
|
|
fn episode(number: u16, air_date: Option<SystemTime>, state: MediaState) -> Episode {
|
|
Episode {
|
|
id: EpisodeId(i64::from(number)),
|
|
season_id: SeasonId(1),
|
|
season_number: 1,
|
|
number,
|
|
title: format!("Episode {number}"),
|
|
air_date,
|
|
wanted: true,
|
|
state,
|
|
search_attempts: 0,
|
|
last_searched_at: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn airing_when_a_wanted_episode_is_in_the_window() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
for air_date in [now - 14 * DAY, now + 14 * DAY] {
|
|
let episodes = [episode(1, Some(air_date), MediaState::Missing)];
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Airing
|
|
);
|
|
}
|
|
|
|
let just_outside = Duration::from_nanos(1);
|
|
let episodes = [episode(
|
|
1,
|
|
Some(now + 14 * DAY + just_outside),
|
|
MediaState::Missing,
|
|
)];
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Waiting
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn incomplete_when_an_aired_wanted_episode_is_missing() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Incomplete
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn waiting_when_the_next_wanted_episode_is_future_dated() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)];
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Waiting
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn complete_when_every_one_off_episode_is_available() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let episodes = [episode(1, Some(now), MediaState::Available)];
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(false, false), &episodes, now),
|
|
SeriesStatus::Complete
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ended_when_upstream_finished_and_everything_is_available() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let episodes = [episode(1, Some(now), MediaState::Available)];
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(true, true), &episodes, now),
|
|
SeriesStatus::Ended
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn satisfied_one_off_grab_leaves_the_default_view() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let mut episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)];
|
|
assert_eq!(
|
|
derive_series_status(&series(false, false), &episodes, now),
|
|
SeriesStatus::Incomplete
|
|
);
|
|
|
|
episodes[0].state = MediaState::Available;
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(false, false), &episodes, now),
|
|
SeriesStatus::Complete
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tracked_show_reappears_when_a_new_season_is_announced() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Waiting
|
|
);
|
|
|
|
episodes.push(episode(2, Some(now + 7 * DAY), MediaState::Missing));
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Airing
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_specials_do_not_hold_back_complete_or_ended() {
|
|
let now = SystemTime::UNIX_EPOCH + 100 * DAY;
|
|
let mut episodes = vec![episode(1, Some(now - 30 * DAY), MediaState::Available)];
|
|
let mut special = episode(1, Some(now - 90 * DAY), MediaState::Missing);
|
|
special.season_id = SeasonId(9);
|
|
special.season_number = 0;
|
|
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Waiting
|
|
);
|
|
|
|
episodes.push(special);
|
|
assert_eq!(
|
|
derive_series_status(&series(true, false), &episodes, now),
|
|
SeriesStatus::Waiting,
|
|
"#131: the season number rides on the episode, so the special is \
|
|
invisible however the caller builds the slice"
|
|
);
|
|
assert_eq!(
|
|
derive_series_status(&series(false, false), &episodes, now),
|
|
SeriesStatus::Complete,
|
|
"§4.2: the only gap is a special"
|
|
);
|
|
assert_eq!(
|
|
derive_series_status(&series(false, true), &episodes, now),
|
|
SeriesStatus::Ended
|
|
);
|
|
}
|
|
}
|