fix(api): newest-first season and episode order
DESIGN.md §9.6 now puts the current season at the top: seasons descend by number within a series (season 0 lands last under plain numeric descending) and episodes descend within each season. Changed the two queries in load_seasons; the SPA renders this order as given. arr-compat checked and untouched: it reads seasons with its own ORDER BY number query straight from the database, never through arr-api, and SeasonResource carries season_number, which Jellyseerr matches on rather than position. Sonarr's real API also returns seasons ascending, so the shim keeps the contract it emulates. The existing vanished-flag test indexed seasons positionally; it now looks them up by number so it tests the flag, not the order. New test asserts [2, 1, 0] for seasons and descending episodes within each. .sqlx regenerated via just db-prepare. just ci green locally.
This commit is contained in:
+2
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
@@ -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<Vec<Season>, 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<Vec<Season>, 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<i64> = 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<i64> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user