Files
arr/crates/arr-core/src/tracking.rs
T
Miguel Palhas b51386e004 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.
2026-08-23 18:18:26 +01:00

215 lines
6.5 KiB
Rust

use crate::{Episode, Season, Series};
/// A season returned by metadata refresh, classified against persisted seasons.
#[derive(Debug)]
pub struct RefreshedSeason {
pub season: Season,
pub episodes: Vec<Episode>,
pub is_new: bool,
}
/// Applies a series' tracking rule to newly revealed seasons.
///
/// Existing seasons keep their tracking rule and leaf-level intent unchanged.
/// Season 0 is never tracked by the rule (`DESIGN.md` §4.1); an operator may
/// still track it by hand.
pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
if !series.auto_track {
return;
}
for refreshed in seasons
.iter_mut()
.filter(|season| season.is_new && season.season.number != 0)
{
refreshed.season.tracked = true;
for episode in &mut refreshed.episodes {
episode.wanted = true;
}
}
}
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
/// already-revealed episodes.
///
/// Turning tracking on marks every revealed episode wanted; future reveals
/// follow while the season stays tracked. Turning it off withdraws nothing:
/// leaf intent is never removed implicitly.
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
if tracked {
for episode in episodes {
episode.wanted = true;
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::{apply_auto_track, apply_tracked, RefreshedSeason};
use crate::{
Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId,
TitleOverrides,
};
fn series(auto_track: 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: false,
blocked: false,
}
}
fn episode(season: i64, number: u16, wanted: bool) -> Episode {
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),
wanted,
state: MediaState::Missing,
search_attempts: 0,
last_searched_at: None,
}
}
fn refreshed_season(number: u16, is_new: bool, wanted: &[bool]) -> RefreshedSeason {
let id = i64::from(number);
RefreshedSeason {
season: Season {
id: SeasonId(id),
series_id: SeriesId(1),
number,
tracked: false,
},
episodes: wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
id,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect(),
is_new,
}
}
#[test]
fn auto_track_marks_only_new_seasons_and_their_episodes() {
let mut refresh = [
refreshed_season(2, false, &[false, false]),
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(true), &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
assert!(refresh[1].season.tracked);
assert!(refresh[1].episodes.iter().all(|episode| episode.wanted));
}
#[test]
fn untracked_series_keeps_manual_leaf_intent_only() {
let mut refresh = [
refreshed_season(2, false, &[true, true, true]),
refreshed_season(3, true, &[false, false]),
];
apply_auto_track(&series(false), &mut refresh);
assert!(refresh[0].episodes.iter().all(|episode| episode.wanted));
assert!(!refresh[0].season.tracked);
assert!(refresh[1].episodes.iter().all(|episode| !episode.wanted));
assert!(!refresh[1].season.tracked);
}
#[test]
fn auto_track_skips_season_zero() {
let mut refresh = [refreshed_season(0, true, &[false])];
apply_auto_track(&series(true), &mut refresh);
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
}
#[test]
fn tracked_on_marks_revealed_episodes_wanted() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
apply_tracked(true, &mut episodes);
assert!(
episodes.iter().all(|episode| episode.wanted),
"tracked on must mark every revealed episode wanted, input {wanted:?}"
);
}
}
#[test]
fn tracked_off_withdraws_nothing() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
let before = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
apply_tracked(false, &mut episodes);
let after = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
assert_eq!(before, after, "tracked off must not touch intent");
}
}
}