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
+79 -17
View File
@@ -16,7 +16,7 @@
use std::collections::HashMap;
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::{
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId,
SeriesStatus, TitleOverrides,
@@ -153,9 +153,11 @@ pub struct CreateEpisode {
/// Both fields of a season a person can set by hand.
///
/// Turning `tracked` on marks every already-revealed episode wanted, and
/// episodes revealed later follow while it stays on; turning it off withdraws
/// nothing (§4.1). `wanted` is the one-click "grab this season", and writes
/// intent onto every episode already in it — the two are deliberately
/// episodes revealed later follow while it stays on; turning it off clears
/// `wanted` on all of them (§4.1, #171) — idempotently, so re-sending
/// `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).
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateSeason {
@@ -870,24 +872,40 @@ pub async fn update_season(
.ok_or(ApiError::SeasonNotFound)?;
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 {
sqlx::query!(
"UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
tracked,
season_id
)
.execute(pool(&state)?)
.execute(&mut *transaction)
.await?;
// §4.1, as `arr_core::tracking::apply_tracked` decides it: turning
// tracking on marks every already-revealed episode wanted; turning it
// off withdraws nothing. Only the off -> on transition writes intent.
if tracked && !season.tracked {
// tracking on marks every already-revealed episode wanted; turning
// it off clears them all, season 0 included. The rule runs even when
// the flag already holds this value (#171): re-sending `false` to an
// untracked season is how stranded wanted rows get cleared.
let rows = sqlx::query_as!(
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
)
.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 season_id = ?",
true,
season_id
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
episode.wanted,
episode.id.0
)
.execute(pool(&state)?)
.execute(&mut *transaction)
.await?;
}
}
@@ -899,9 +917,10 @@ pub async fn update_season(
wanted,
season_id
)
.execute(pool(&state)?)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
let seasons = load_seasons(&state, series_id).await?;
seasons
@@ -1768,8 +1787,12 @@ mod tests {
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]
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(
base: &str,
series_id: i64,
@@ -1803,6 +1826,26 @@ mod tests {
assert_eq!(season["tracked"], 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;
assert_eq!(updated["tracked"], true);
assert!(
@@ -1814,7 +1857,6 @@ mod tests {
"§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;
assert_eq!(updated["tracked"], false);
assert!(
@@ -1822,11 +1864,31 @@ mod tests {
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true),
"§4.1: leaf intent is never removed implicitly"
.all(|episode| episode["wanted"] == false),
"§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]
async fn listed_series_carry_a_derived_status() {
let (_dir, state, base) = application().await;