feat(core): derive series status (#80)
ci / rust (push) Successful in 1m49s
ci / web (push) Successful in 31s
e2e / e2e (push) Successful in 2m1s

This commit was merged in pull request #80.
This commit is contained in:
2026-08-22 22:38:36 +01:00
parent f253e2755b
commit f26f278a75
2 changed files with 208 additions and 0 deletions
+2
View File
@@ -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) => {
+206
View File
@@ -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<SystemTime>, 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
);
}
}