feat: untracking a season clears its wanted
This commit is contained in:
+86
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// 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!(
|
sqlx::query!(
|
||||||
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?",
|
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?",
|
||||||
true,
|
episode.wanted,
|
||||||
season_id
|
episode.id.0
|
||||||
)
|
)
|
||||||
.execute(pool(&state)?)
|
.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;
|
||||||
|
|||||||
@@ -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 = tracked;
|
||||||
episode.wanted = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user