From b51386e0043b10fcaebfce0ea538e27706cdaa48 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 18:18:26 +0100 Subject: [PATCH] core: carry season number on Episode 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. --- crates/arr-core/src/lib.rs | 3 ++ crates/arr-core/src/status.rs | 70 ++++++++++++--------------------- crates/arr-core/src/tracking.rs | 1 + 3 files changed, 29 insertions(+), 45 deletions(-) diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 7bd3101..4c5abde 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -288,6 +288,9 @@ pub struct Season { pub struct Episode { pub id: EpisodeId, pub season_id: SeasonId, + /// The owning season's number, carried so status derivation never needs + /// a second slice a caller can forget (#131). + pub season_number: u16, pub number: u16, pub title: String, pub air_date: Option, diff --git a/crates/arr-core/src/status.rs b/crates/arr-core/src/status.rs index 75abfb8..71fe92b 100644 --- a/crates/arr-core/src/status.rs +++ b/crates/arr-core/src/status.rs @@ -1,6 +1,6 @@ use std::time::{Duration, SystemTime}; -use crate::{Episode, MediaState, Season, Series}; +use crate::{Episode, MediaState, Series}; const AIRING_WINDOW: Duration = Duration::from_hours(14 * 24); @@ -16,26 +16,19 @@ pub enum SeriesStatus { /// Derives a series' status at `now` without persisting lifecycle intent. /// -/// Season 0 is invisible to all of it (`DESIGN.md` §4.2): episodes in a -/// season 0 listed here are ignored, so a manually wanted special cannot pin -/// the series at `incomplete` or hold back `ended`. The seasons must be the -/// series' own; an episode whose season is not listed counts like any other. +/// 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, - seasons: &[Season], episodes: &[Episode], now: SystemTime, ) -> SeriesStatus { - // A series has at most one season 0, so this is a comparison rather than - // a set: no allocation on a function the list view calls per series. - let specials = seasons - .iter() - .find(|season| season.number == 0) - .map(|season| season.id); let wanted = episodes .iter() - .filter(|episode| episode.wanted && specials != Some(episode.season_id)); + .filter(|episode| episode.wanted && episode.season_number != 0); let complete = wanted .clone() .all(|episode| episode.state == MediaState::Available); @@ -107,6 +100,7 @@ mod tests { Episode { id: EpisodeId(i64::from(number)), season_id: SeasonId(1), + season_number: 1, number, title: format!("Episode {number}"), air_date, @@ -117,23 +111,13 @@ mod tests { } } - /// The season every default-test episode sits in. - fn seasons() -> [Season; 1] { - [Season { - id: SeasonId(1), - series_id: SeriesId(1), - number: 1, - tracked: true, - }] - } - #[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), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Airing ); } @@ -145,7 +129,7 @@ mod tests { MediaState::Missing, )]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); } @@ -156,7 +140,7 @@ mod tests { let episodes = [episode(1, Some(now - 20 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Incomplete ); } @@ -167,7 +151,7 @@ mod tests { let episodes = [episode(1, Some(now + 30 * DAY), MediaState::Missing)]; assert_eq!( - derive_series_status(&series(true, false), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); } @@ -178,7 +162,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(false, false), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Complete ); } @@ -189,7 +173,7 @@ mod tests { let episodes = [episode(1, Some(now), MediaState::Available)]; assert_eq!( - derive_series_status(&series(true, true), &seasons(), &episodes, now), + derive_series_status(&series(true, true), &episodes, now), SeriesStatus::Ended ); } @@ -199,14 +183,14 @@ mod tests { 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), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Incomplete ); episodes[0].state = MediaState::Available; assert_eq!( - derive_series_status(&series(false, false), &seasons(), &episodes, now), + derive_series_status(&series(false, false), &episodes, now), SeriesStatus::Complete ); } @@ -216,14 +200,14 @@ mod tests { 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), &seasons(), &episodes, now), + 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), &seasons(), &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Airing ); } @@ -231,34 +215,30 @@ mod tests { #[test] fn missing_specials_do_not_hold_back_complete_or_ended() { let now = SystemTime::UNIX_EPOCH + 100 * DAY; - let specials = [Season { - id: SeasonId(9), - series_id: SeriesId(1), - number: 0, - tracked: false, - }]; 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), &specials, &episodes, now), + derive_series_status(&series(true, false), &episodes, now), SeriesStatus::Waiting ); episodes.push(special); assert_eq!( - derive_series_status(&series(true, false), &[], &episodes, now), - SeriesStatus::Incomplete, - "without knowing the seasons, the special pins the series" + 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), &specials, &episodes, now), + 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), &specials, &episodes, now), + derive_series_status(&series(false, true), &episodes, now), SeriesStatus::Ended ); } diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index 6829781..aeb9793 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -72,6 +72,7 @@ mod tests { Episode { id: EpisodeId(season * 100 + i64::from(number)), season_id: SeasonId(season), + season_number: u16::try_from(season).unwrap_or_default(), number, title: format!("Episode {number}"), air_date: Some(SystemTime::UNIX_EPOCH),