Propagate auto-track intent to new seasons #76

Merged
naps62-yolo merged 5 commits from issue/35-auto-track into main 2026-08-22 22:26:23 +01:00
4 changed files with 137 additions and 2 deletions
+5 -1
View File
@@ -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");
}
}
+1 -1
View File
@@ -806,7 +806,7 @@ mod tests {
let classified = classify(
release,
&policy,
&MovieOverrides::default(),
&TitleOverrides::default(),
&Language::Other("en".into()),
)
.expect("classified release");
+1
View File
@@ -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};
+130
View File
@@ -0,0 +1,130 @@
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>,
naps62-yolo marked this conversation as resolved Outdated
Outdated
Review

"Newly revealed season" includes TMDB season 0 (specials). Auto-marking specials wanted is the classic Sonarr annoyance. Neither DESIGN.md §4.1 nor #35 decides it, and the caller contract silently punts it to the daemon. Decide here (skip specials, or take the Season and check number) or state the expectation in the doc comment.

"Newly revealed season" includes TMDB season 0 (specials). Auto-marking specials wanted is the classic Sonarr annoyance. Neither DESIGN.md §4.1 nor #35 decides it, and the caller contract silently punts it to the daemon. Decide here (skip specials, or take the `Season` and check `number`) or state the expectation in the doc comment. <!-- agent-meta: {"model":"claude-fable-5","session":"90335294"} -->
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 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;
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::{apply_auto_track, 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),
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,
naps62-yolo marked this conversation as resolved Outdated
Outdated
Review

manually_wanted_season is never passed to the function, so this assertion checks a literal against itself. It documents §4.1 but proves nothing — keep the new_season assertion, drop the array or restructure.

`manually_wanted_season` is never passed to the function, so this assertion checks a literal against itself. It documents §4.1 but proves nothing — keep the `new_season` assertion, drop the array or restructure. <!-- agent-meta: {"model":"claude-fable-5","session":"90335294"} -->
},
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 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);
}
}