255 lines
8.5 KiB
Rust
255 lines
8.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.
|
|
///
|
|
/// `ever_refreshed` says whether the series has had a metadata refresh yet
|
|
/// (`metadata_refreshed_at IS NOT NULL`). The caller supplies the fact because
|
|
/// `arr-core` has no IO; seasons revealed by a series' first refresh are its
|
|
/// back catalogue as it stood at add time, and §4.1 never tracks those — the
|
|
/// rule applies from the second refresh onward.
|
|
pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [RefreshedSeason]) {
|
|
if !series.auto_track || !ever_refreshed {
|
|
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, and future
|
|
/// reveals follow while the season stays tracked. Turning it off clears
|
|
/// `wanted` on all of them: toggling the flag is itself the explicit act,
|
|
/// and the product offers no other way to withdraw a season's worth of
|
|
/// intent. The rule is idempotent and must run even when the flag already
|
|
/// has the value asked for (#171) — an operator re-sending `tracked: false`
|
|
/// to an already-untracked season is clearing stranded intent.
|
|
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
|
|
for episode in episodes {
|
|
episode.wanted = tracked;
|
|
}
|
|
}
|
|
|
|
#[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), 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));
|
|
}
|
|
|
|
/// #160. The first refresh of a newly added series reveals the whole back
|
|
/// catalogue at once; §4.1 never tracks those.
|
|
#[test]
|
|
fn the_seeding_refresh_tracks_nothing() {
|
|
let mut refresh = [
|
|
refreshed_season(1, true, &[false, false]),
|
|
refreshed_season(2, true, &[false]),
|
|
];
|
|
|
|
apply_auto_track(&series(true), false, &mut refresh);
|
|
|
|
assert!(refresh.iter().all(|refreshed| !refreshed.season.tracked));
|
|
assert!(refresh
|
|
.iter()
|
|
.all(|refreshed| refreshed.episodes.iter().all(|episode| !episode.wanted)));
|
|
}
|
|
|
|
/// #160. From the second refresh onward a season that genuinely did not
|
|
/// exist when the series was added is tracked, and its episodes arrive
|
|
/// wanted — while the seasons already on record stay as they were.
|
|
#[test]
|
|
fn a_later_refresh_tracks_a_genuinely_new_season() {
|
|
let mut refresh = [
|
|
refreshed_season(1, false, &[true, true]),
|
|
refreshed_season(3, true, &[false]),
|
|
];
|
|
|
|
apply_auto_track(&series(true), true, &mut refresh);
|
|
|
|
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), true, &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), 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:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// #171. Toggling the flag is the explicit act, so `false` clears
|
|
/// wanted on every revealed episode — including when it is re-sent to a
|
|
/// season that was already untracked, which is the only way out for
|
|
/// rows stranded by the old ruling.
|
|
#[test]
|
|
fn tracked_off_clears_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(false, &mut episodes);
|
|
|
|
assert!(
|
|
episodes.iter().all(|episode| !episode.wanted),
|
|
"tracked off must clear every revealed episode, input {wanted:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|