Merge #119: seasons.tracked governs episode intent

Closes #119
This commit is contained in:
Miguel Palhas
2026-08-23 16:54:47 +01:00
4 changed files with 185 additions and 26 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.
@@ -1390,6 +1405,65 @@ mod tests {
assert_eq!(episode["wanted"], false);
}
#[tokio::test]
async fn tracking_a_season_marks_revealed_episodes_and_off_withdraws_nothing() {
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 (_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);
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;
+80 -1
View File
@@ -29,11 +29,25 @@ pub fn apply_auto_track(series: &Series, seasons: &mut [RefreshedSeason]) {
}
}
/// Applies a season's tracking rule (`DESIGN.md` §4.1) to its
/// already-revealed episodes.
///
/// Turning tracking on marks every revealed episode wanted; future reveals
/// follow while the season stays tracked. Turning it off withdraws nothing:
/// leaf intent is never removed implicitly.
pub fn apply_tracked(tracked: bool, episodes: &mut [Episode]) {
if tracked {
for episode in episodes {
episode.wanted = true;
}
}
}
#[cfg(test)]
mod tests {
use std::time::SystemTime;
use super::{apply_auto_track, RefreshedSeason};
use super::{apply_auto_track, apply_tracked, RefreshedSeason};
use crate::{
Episode, EpisodeId, Language, MediaState, RootId, Season, SeasonId, Series, SeriesId,
TitleOverrides,
@@ -131,4 +145,69 @@ mod tests {
assert!(!refresh[0].season.tracked);
assert!(refresh[0].episodes.iter().all(|episode| !episode.wanted));
}
#[test]
fn tracked_on_marks_revealed_episodes_wanted() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
apply_tracked(true, &mut episodes);
assert!(
episodes.iter().all(|episode| episode.wanted),
"tracked on must mark every revealed episode wanted, input {wanted:?}"
);
}
}
#[test]
fn tracked_off_withdraws_nothing() {
let cases: [&[bool]; 4] = [
&[],
&[false],
&[true, false, false],
&[false, true, false, true],
];
for wanted in cases {
let mut episodes = wanted
.iter()
.enumerate()
.map(|(index, wanted)| {
episode(
1,
u16::try_from(index + 1).expect("episode number fits"),
*wanted,
)
})
.collect::<Vec<_>>();
let before = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
apply_tracked(false, &mut episodes);
let after = episodes
.iter()
.map(|episode| episode.wanted)
.collect::<Vec<_>>();
assert_eq!(before, after, "tracked off must not touch intent");
}
}
}