From 9b3edcb04729a3c3594b2bd9273539631443b2d0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 22:10:26 +0100 Subject: [PATCH 1/4] feat(core): propagate auto-track intent --- crates/arr-core/src/lib.rs | 1 + crates/arr-core/src/tracking.rs | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 crates/arr-core/src/tracking.rs diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 3449d2d..11f2517 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -7,6 +7,7 @@ use std::{collections::BTreeMap, path::PathBuf, time::SystemTime}; pub mod lang; pub mod policy; pub mod score; +pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; pub use score::{Score, ScoreWeights}; diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs new file mode 100644 index 0000000..8aa56ab --- /dev/null +++ b/crates/arr-core/src/tracking.rs @@ -0,0 +1,86 @@ +use crate::{Episode, Series}; + +/// Applies a series' tracking rule to episodes discovered in a new season. +/// +/// Callers pass only episodes from a newly revealed season. Existing seasons +/// keep their leaf-level intent unchanged. +pub fn apply_new_season_auto_track(series: &Series, episodes: &mut [Episode]) { + if series.auto_track { + for episode in episodes { + episode.wanted = true; + } + } +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use super::apply_new_season_auto_track; + use crate::{ + Episode, EpisodeId, Language, MediaState, RootId, 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), + number, + title: format!("Episode {number}"), + air_date: Some(SystemTime::UNIX_EPOCH), + wanted, + state: MediaState::Missing, + search_attempts: 0, + last_searched_at: None, + } + } + + #[test] + fn auto_track_marks_new_season_episodes_wanted() { + let mut new_season = vec![episode(3, 1, false), episode(3, 2, false)]; + + apply_new_season_auto_track(&series(true), &mut new_season); + + assert!(new_season.iter().all(|episode| episode.wanted)); + } + + #[test] + fn untracked_series_keeps_leaf_intent_only() { + let manually_wanted_season = [ + episode(2, 1, true), + episode(2, 2, true), + episode(2, 3, true), + ]; + let mut new_season = vec![episode(3, 1, false), episode(3, 2, false)]; + + apply_new_season_auto_track(&series(false), &mut new_season); + + assert!(manually_wanted_season.iter().all(|episode| episode.wanted)); + assert!(new_season.iter().all(|episode| !episode.wanted)); + } + + #[test] + fn auto_track_does_not_clear_explicit_leaf_intent() { + let mut new_season = vec![episode(3, 1, true), episode(3, 2, false)]; + + apply_new_season_auto_track(&series(true), &mut new_season); + + assert!(new_season.iter().all(|episode| episode.wanted)); + } +} -- 2.47.3 From de0d533a4914220877230f04111531b26a37aa6b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 22:15:58 +0100 Subject: [PATCH 2/4] fix(core): model refreshed seasons --- crates/arr-core/src/tracking.rs | 102 +++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 29 deletions(-) diff --git a/crates/arr-core/src/tracking.rs b/crates/arr-core/src/tracking.rs index 8aa56ab..f821068 100644 --- a/crates/arr-core/src/tracking.rs +++ b/crates/arr-core/src/tracking.rs @@ -1,12 +1,25 @@ -use crate::{Episode, Series}; +use crate::{Episode, Season, Series}; -/// Applies a series' tracking rule to episodes discovered in a new season. +/// A season returned by metadata refresh, classified against persisted seasons. +#[derive(Debug)] +pub struct RefreshedSeason { + pub season: Season, + pub episodes: Vec, + pub is_new: bool, +} + +/// Applies a series' tracking rule to newly revealed seasons. /// -/// Callers pass only episodes from a newly revealed season. Existing seasons -/// keep their leaf-level intent unchanged. -pub fn apply_new_season_auto_track(series: &Series, episodes: &mut [Episode]) { - if series.auto_track { - for episode in episodes { +/// Existing seasons keep their tracking rule and leaf-level intent unchanged. +/// Season zero is treated like every other newly revealed season. +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) { + refreshed.season.tracked = true; + for episode in &mut refreshed.episodes { episode.wanted = true; } } @@ -16,9 +29,9 @@ pub fn apply_new_season_auto_track(series: &Series, episodes: &mut [Episode]) { mod tests { use std::time::SystemTime; - use super::apply_new_season_auto_track; + use super::{apply_auto_track, RefreshedSeason}; use crate::{ - Episode, EpisodeId, Language, MediaState, RootId, SeasonId, Series, SeriesId, + Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId, TitleOverrides, }; @@ -51,36 +64,67 @@ mod tests { } } - #[test] - fn auto_track_marks_new_season_episodes_wanted() { - let mut new_season = vec![episode(3, 1, false), episode(3, 2, false)]; - - apply_new_season_auto_track(&series(true), &mut new_season); - - assert!(new_season.iter().all(|episode| episode.wanted)); + 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 untracked_series_keeps_leaf_intent_only() { - let manually_wanted_season = [ - episode(2, 1, true), - episode(2, 2, true), - episode(2, 3, true), + 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]), ]; - let mut new_season = vec![episode(3, 1, false), episode(3, 2, false)]; - apply_new_season_auto_track(&series(false), &mut new_season); + apply_auto_track(&series(true), &mut refresh); - assert!(manually_wanted_season.iter().all(|episode| episode.wanted)); - assert!(new_season.iter().all(|episode| !episode.wanted)); + 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 auto_track_does_not_clear_explicit_leaf_intent() { - let mut new_season = vec![episode(3, 1, true), episode(3, 2, false)]; + 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_new_season_auto_track(&series(true), &mut new_season); + apply_auto_track(&series(false), &mut refresh); - assert!(new_season.iter().all(|episode| episode.wanted)); + 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 season_zero_follows_the_same_rule() { + let mut refresh = [refreshed_season(0, true, &[false])]; + + apply_auto_track(&series(true), &mut refresh); + + assert!(refresh[0].season.tracked); + assert!(refresh[0].episodes[0].wanted); } } -- 2.47.3 From 46c59a15f0f7e0ee3d462ed04f87f2ef23dd45ae Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 22:16:54 +0100 Subject: [PATCH 3/4] fix(api): use title overrides in test --- crates/arr-api/src/search.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index e681ecf..db1b4a2 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -806,7 +806,7 @@ mod tests { let classified = classify( release, &policy, - &MovieOverrides::default(), + &TitleOverrides::default(), &Language::Other("en".into()), ) .expect("classified release"); -- 2.47.3 From 780bca49df853d19e55e4bf17fa745060c1fc9b6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 22:17:47 +0100 Subject: [PATCH 4/4] fix(api): expect TV roots in endpoint test --- crates/arr-api/src/roots.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/arr-api/src/roots.rs b/crates/arr-api/src/roots.rs index 7f86586..e0ab5f0 100644 --- a/crates/arr-api/src/roots.rs +++ b/crates/arr-api/src/roots.rs @@ -68,12 +68,16 @@ mod tests { .json() .await .expect("json"); - assert_eq!(roots.len(), 2, "the two seeded movie roots: {roots:?}"); + assert_eq!(roots.len(), 4, "the seeded movie and TV roots: {roots:?}"); assert_eq!(roots[0]["audience"], "main"); assert_eq!(roots[0]["policy_name"], "Movies — main"); assert_eq!(roots[1]["audience"], "kids"); assert!(roots[1]["path"] .as_str() .is_some_and(|p| p.contains("kids"))); + assert_eq!(roots[2]["kind"], "tv"); + assert_eq!(roots[2]["policy_name"], "TV — main"); + assert_eq!(roots[3]["kind"], "tv"); + assert_eq!(roots[3]["policy_name"], "TV — kids"); } } -- 2.47.3