feat(api): season tracked toggle marks revealed episodes

This commit is contained in:
Miguel Palhas
2026-08-23 16:50:45 +01:00
parent 135d4b8247
commit fa3b5226e7
3 changed files with 105 additions and 25 deletions
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tracked AS \"tracked!: bool\" FROM seasons WHERE series_id = ? AND number = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "tracked!: bool",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false
]
},
"hash": "3bc011ffdc966aa9ec540286bb728c4adf0257f1ab78a1cd23d27dd4dc2e0be1"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\" FROM seasons WHERE series_id = ? AND number = ?",
"describe": {
"columns": [
{
"name": "id!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "b6974993af21da17e3f935cd0ab3cc4e14a9e7cf00e0decc15186ec39b74cf30"
}
+79 -5
View File
@@ -135,9 +135,11 @@ pub struct CreateEpisode {
/// Both fields of a season a person can set by hand.
///
/// `tracked` changes the rule for episodes not yet revealed. `wanted` is the
/// one-click "grab this season", and writes intent onto every episode already
/// in it — the two are deliberately separate (§4.1).
/// 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
/// separate (§4.1).
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateSeason {
pub tracked: Option<bool>,
@@ -775,14 +777,15 @@ pub async fn update_season(
Json(input): Json<UpdateSeason>,
) -> Result<Json<Season>, ApiError> {
load_series_row(&state, series_id).await?;
let season_id = sqlx::query_scalar!(
r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#,
let season = sqlx::query!(
r#"SELECT id AS "id!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? AND number = ?"#,
series_id,
number
)
.fetch_optional(pool(&state)?)
.await?
.ok_or(ApiError::SeasonNotFound)?;
let season_id = season.id;
if let Some(tracked) = input.tracked {
sqlx::query!(
@@ -792,6 +795,18 @@ pub async fn update_season(
)
.execute(pool(&state)?)
.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 {
sqlx::query!(
"UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?",
true,
season_id
)
.execute(pool(&state)?)
.await?;
}
}
// §4.1. Marking a season wanted is intent written onto its episodes, so
// an untracked series with one wanted season needs no special case.
@@ -1276,6 +1291,65 @@ mod tests {
assert_eq!(episode["wanted"], false);
}
#[tokio::test]
async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() {
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!([
{"number": 1, "title": "One", "air_date": "2020-01-01"},
{"number": 2, "title": "Two", "air_date": "2020-01-08"}
]),
)
.await;
assert_eq!(season["tracked"], false);
assert_eq!(season["episodes"][0]["wanted"], false);
async fn patch_season(
base: &str,
series_id: i64,
body: serde_json::Value,
) -> serde_json::Value {
reqwest::Client::new()
.patch(format!("{base}/api/series/{series_id}/seasons/1"))
.json(&body)
.send()
.await
.expect("update season")
.json()
.await
.expect("season json")
}
let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await;
assert_eq!(updated["tracked"], true);
assert!(
updated["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true),
"§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!(
updated["episodes"]
.as_array()
.expect("episodes")
.iter()
.all(|episode| episode["wanted"] == true),
"§4.1: leaf intent is never removed implicitly"
);
}
#[tokio::test]
async fn listed_series_carry_a_derived_status() {
let (_dir, state, base) = application().await;