feat: untracking a season clears its wanted

This commit is contained in:
Miguel Palhas
2026-08-24 16:31:09 +01:00
parent 60642d8709
commit 9e003823c6
3 changed files with 183 additions and 34 deletions
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT se.series_id AS \"series_id!: i64\", e.id AS \"id!: i64\", e.season_id AS \"season_id!: i64\", se.number AS \"season_number!: i64\", e.number AS \"number!: i64\", e.title AS \"title!: String\", e.air_date, e.wanted AS \"wanted!: bool\", e.state AS \"state!: String\", e.vanished AS \"vanished!: bool\", e.search_attempts AS \"search_attempts!: i64\", e.last_searched_at\n FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?",
"describe": {
"columns": [
{
"name": "series_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "id!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "season_id!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "number!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "air_date",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "wanted!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "state!: String",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "vanished!: bool",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "search_attempts!: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "last_searched_at",
"ordinal": 11,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
false,
false,
true,
false,
false,
false,
false,
true
]
},
"hash": "3a02f30ced2765dcdbf7575d680c1b4ab1db895065f65db4a99bb0a4dc6e71ed"
}
+79 -17
View File
@@ -16,7 +16,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use arr_core::tracking::{apply_auto_track, RefreshedSeason}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
use arr_core::{ use arr_core::{
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
SeriesStatus, TitleOverrides, SeriesStatus, TitleOverrides,
@@ -153,9 +153,11 @@ pub struct CreateEpisode {
/// Both fields of a season a person can set by hand. /// Both fields of a season a person can set by hand.
/// ///
/// Turning `tracked` on marks every already-revealed episode wanted, and /// Turning `tracked` on marks every already-revealed episode wanted, and
/// episodes revealed later follow while it stays on; turning it off withdraws /// episodes revealed later follow while it stays on; turning it off clears
/// nothing (§4.1). `wanted` is the one-click "grab this season", and writes /// `wanted` on all of them (§4.1, #171) — idempotently, so re-sending
/// intent onto every episode already in it — the two are deliberately /// `tracked: false` to an already-untracked season still clears its
/// stranded wanted rows. `wanted` is the one-click "grab this season", and
/// writes intent onto every episode already in it — the two are deliberately
/// separate (§4.1). /// separate (§4.1).
#[derive(Debug, Deserialize, ToSchema)] #[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateSeason { pub struct UpdateSeason {
@@ -870,24 +872,40 @@ pub async fn update_season(
.ok_or(ApiError::SeasonNotFound)?; .ok_or(ApiError::SeasonNotFound)?;
let season_id = season.id; let season_id = season.id;
// One transaction: the flag write and the episode intent it implies
// land together or not at all (#171).
let mut transaction = pool(&state)?.begin().await?;
if let Some(tracked) = input.tracked { if let Some(tracked) = input.tracked {
sqlx::query!( sqlx::query!(
"UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", "UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
tracked, tracked,
season_id season_id
) )
.execute(pool(&state)?) .execute(&mut *transaction)
.await?; .await?;
// §4.1, as `arr_core::tracking::apply_tracked` decides it: turning // §4.1, as `arr_core::tracking::apply_tracked` decides it: turning
// tracking on marks every already-revealed episode wanted; turning it // tracking on marks every already-revealed episode wanted; turning
// off withdraws nothing. Only the off -> on transition writes intent. // it off clears them all, season 0 included. The rule runs even when
if tracked && !season.tracked { // the flag already holds this value (#171): re-sending `false` to an
sqlx::query!( // untracked season is how stranded wanted rows get cleared.
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?", let rows = sqlx::query_as!(
true, EpisodeRow,
r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at
FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#,
season_id season_id
) )
.execute(pool(&state)?) .fetch_all(&mut *transaction)
.await?;
let mut episodes: Vec<_> = rows.iter().map(core_episode).collect();
apply_tracked(tracked, &mut episodes);
for episode in &episodes {
sqlx::query!(
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
episode.wanted,
episode.id.0
)
.execute(&mut *transaction)
.await?; .await?;
} }
} }
@@ -899,9 +917,10 @@ pub async fn update_season(
wanted, wanted,
season_id season_id
) )
.execute(pool(&state)?) .execute(&mut *transaction)
.await?; .await?;
} }
transaction.commit().await?;
let seasons = load_seasons(&state, series_id).await?; let seasons = load_seasons(&state, series_id).await?;
seasons seasons
@@ -1768,8 +1787,12 @@ mod tests {
assert_eq!(episode["wanted"], false); assert_eq!(episode["wanted"], false);
} }
/// #171. The §4.1 rule runs whenever the flag is sent: off clears
/// wanted — including on a season that was already untracked, which is
/// how rows stranded by the old ruling get out — and on marks every
/// revealed episode wanted.
#[tokio::test] #[tokio::test]
async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() { async fn untracking_a_season_clears_its_episodes_wanted() {
async fn patch_season( async fn patch_season(
base: &str, base: &str,
series_id: i64, series_id: i64,
@@ -1803,6 +1826,26 @@ mod tests {
assert_eq!(season["tracked"], false); assert_eq!(season["tracked"], false);
assert_eq!(season["episodes"][0]["wanted"], false); assert_eq!(season["episodes"][0]["wanted"], false);
// A season stranded by the old ruling: untracked, yet its episodes
// are all wanted. Re-sending the flag it already holds must still
// do the work (#171).
let updated = patch_season(&base, series_id, serde_json::json!({"wanted": true})).await;
assert!(updated["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true));
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await;
assert_eq!(updated["tracked"], false);
assert!(
updated["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == false),
"#171: clearing an already-untracked season is the way out"
);
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await; let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await;
assert_eq!(updated["tracked"], true); assert_eq!(updated["tracked"], true);
assert!( assert!(
@@ -1814,7 +1857,6 @@ mod tests {
"§4.1: turning tracked on marks every revealed episode wanted" "§4.1: turning tracked on marks every revealed episode wanted"
); );
// Turning it off withdraws nothing.
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await;
assert_eq!(updated["tracked"], false); assert_eq!(updated["tracked"], false);
assert!( assert!(
@@ -1822,11 +1864,31 @@ mod tests {
.as_array() .as_array()
.expect("episodes") .expect("episodes")
.iter() .iter()
.all(|episode| episode["wanted"] == true), .all(|episode| episode["wanted"] == false),
"§4.1: leaf intent is never removed implicitly" "§4.1: turning tracked off clears every revealed episode"
); );
} }
/// #171. Untracking a season with no episodes touches nothing and is not
/// an error.
#[tokio::test]
async fn untracking_an_empty_season_is_a_no_op() {
let (_dir, state, base) = application().await;
let root_id = tv_root(&state, "main").await;
let series = add_series(&base, root_id, false).await;
let series_id = series["id"].as_i64().expect("id");
let season = add_season(&base, series_id, 1, serde_json::json!([])).await;
assert_eq!(season["tracked"], false);
let response = reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}/seasons/1"))
.json(&serde_json::json!({"tracked": false}))
.send()
.await
.expect("update season");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test] #[tokio::test]
async fn listed_series_carry_a_derived_status() { async fn listed_series_carry_a_derived_status() {
let (_dir, state, base) = application().await; let (_dir, state, base) = application().await;
+17 -16
View File
@@ -38,14 +38,16 @@ pub fn apply_auto_track(series: &Series, ever_refreshed: bool, seasons: &mut [Re
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its /// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
/// already-revealed episodes. /// already-revealed episodes.
/// ///
/// Turning tracking on marks every revealed episode wanted; future reveals /// Turning tracking on marks every revealed episode wanted, and future
/// follow while the season stays tracked. Turning it off withdraws nothing: /// reveals follow while the season stays tracked. Turning it off clears
/// leaf intent is never removed implicitly. /// `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]) { pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
if tracked {
for episode in episodes { for episode in episodes {
episode.wanted = true; episode.wanted = tracked;
}
} }
} }
@@ -216,8 +218,12 @@ mod tests {
} }
} }
/// #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] #[test]
fn tracked_off_withdraws_nothing() { fn tracked_off_clears_revealed_episodes_wanted() {
let cases: [&[bool]; 4] = [ let cases: [&[bool]; 4] = [
&[], &[],
&[false], &[false],
@@ -236,18 +242,13 @@ mod tests {
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let before = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
apply_tracked(false, &mut episodes); apply_tracked(false, &mut episodes);
let after = episodes assert!(
.iter() episodes.iter().all(|episode| !episode.wanted),
.map(|episode| episode.wanted) "tracked off must clear every revealed episode, input {wanted:?}"
.collect::<Vec<_>>(); );
assert_eq!(before, after, "tracked off must not touch intent");
} }
} }
} }