diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 11f2517..09de6ef 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -7,10 +7,12 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; pub mod lang; pub mod policy; pub mod score; +pub mod status; pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; pub use score::{Score, ScoreWeights}; +pub use status::{derive_series_status, SeriesStatus}; macro_rules! id_type { ($name:ident) => { diff --git a/crates/arr-core/src/status.rs b/crates/arr-core/src/status.rs new file mode 100644 index 0000000..5e737c6 --- /dev/null +++ b/crates/arr-core/src/status.rs @@ -0,0 +1,206 @@ +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. +#[must_use] +pub fn derive_series_status( + series: &Series, + episodes: &[Episode], + now: SystemTime, +) -> SeriesStatus { + let wanted = episodes.iter().filter(|episode| episode.wanted); + 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, state: MediaState) -> Episode { + Episode { + id: EpisodeId(i64::from(number)), + season_id: SeasonId(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 + ); + } +}