diff --git a/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json b/.sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json similarity index 92% rename from .sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json rename to .sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json index e22fd02..ac2fc80 100644 --- a/.sqlx/query-e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187.json +++ b/.sqlx/query-580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a.json @@ -1,6 +1,6 @@ { "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 se.series_id = ? ORDER BY se.number, e.number", + "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 se.series_id = ? ORDER BY se.number DESC, e.number DESC", "describe": { "columns": [ { @@ -82,5 +82,5 @@ true ] }, - "hash": "e6f5f116e453756eb57d58da32dd32a53984f9884b179346b1faf07032b08187" + "hash": "580e3bf81062d253fd08c9a4e1a879a0e57d6357ab9b278503cdf8fb5252886a" } diff --git a/.sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json b/.sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json similarity index 89% rename from .sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json rename to .sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json index 52f33f0..68a8d8c 100644 --- a/.sqlx/query-583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81.json +++ b/.sqlx/query-f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number", + "query": "SELECT id AS \"id!: i64\", series_id AS \"series_id!: i64\", number AS \"number!: i64\", tracked AS \"tracked!: bool\", vanished AS \"vanished!: bool\" FROM seasons WHERE series_id = ? ORDER BY number DESC", "describe": { "columns": [ { @@ -40,5 +40,5 @@ false ] }, - "hash": "583b48cc8a0c618294361f606a9c8a4b3a42000c96343ff9a5490bb518700f81" + "hash": "f52980596624a03ae4c704926a443c5665c865f9d3d3699105790fe09d8b3fc9" } diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index ea32782..bace102 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -655,7 +655,9 @@ async fn remove_library_files(state: &AppState, id: i64) -> Result<(), ApiError> async fn load_seasons(state: &AppState, series_id: i64) -> Result, ApiError> { let seasons = sqlx::query!( - r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number"#, + // §9.6: newest-first, so season 0 lands last under plain numeric + // descending order — exactly where the design puts it. + r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number DESC"#, series_id ) .fetch_all(pool(state)?) @@ -663,7 +665,7 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result, A let episodes = 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 se.series_id = ? ORDER BY se.number, e.number"#, + FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#, series_id ) .fetch_all(pool(state)?) @@ -1637,13 +1639,83 @@ mod tests { .await .expect("seasons json"); let seasons = seasons.as_array().expect("seasons array"); - assert_eq!(seasons[0]["vanished"], false); + let vanished = |number: i64| { + seasons + .iter() + .find(|season| season["number"] == number) + .expect("season")["vanished"] + .clone() + }; + assert_eq!(vanished(1), false); assert_eq!( - seasons[1]["vanished"], true, + vanished(2), + true, "#141: the conflict flag is not stopped at the database" ); } + /// §9.6: seasons come back newest-first within a series and episodes + /// newest-first within each season, with season 0 wherever descending + /// numeric order puts it — last. The SPA renders this order as given, + /// so it is the API's to get right (#162). + #[tokio::test] + async fn seasons_and_episodes_are_returned_newest_first() { + 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"); + for (season, episodes) in [ + ( + 0, + serde_json::json!([ + {"number": 1, "title": "Special one"}, + {"number": 2, "title": "Special two"} + ]), + ), + (1, serde_json::json!([{"number": 1, "title": "Pilot"}])), + ( + 2, + serde_json::json!([ + {"number": 1, "title": "One"}, + {"number": 2, "title": "Two"}, + {"number": 3, "title": "Three"} + ]), + ), + ] { + add_season(&base, series_id, season, episodes).await; + } + + let seasons: serde_json::Value = + reqwest::get(format!("{base}/api/series/{series_id}/seasons")) + .await + .expect("list seasons") + .json() + .await + .expect("seasons json"); + let seasons = seasons.as_array().expect("seasons array"); + let numbers: Vec = seasons + .iter() + .map(|season| season["number"].as_i64().expect("number")) + .collect(); + assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series"); + + let episode_numbers = |index: usize| -> Vec { + seasons[index]["episodes"] + .as_array() + .expect("episodes") + .iter() + .map(|episode| episode["number"].as_i64().expect("episode number")) + .collect() + }; + assert_eq!( + episode_numbers(0), + [3, 2, 1], + "§9.6: newest-first in a season" + ); + assert_eq!(episode_numbers(1), [1]); + assert_eq!(episode_numbers(2), [2, 1]); + } + #[tokio::test] async fn intent_is_set_on_seasons_and_on_single_episodes() { let (_dir, state, base) = application().await;