+26
@@ -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"
|
||||
}
|
||||
-20
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user