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:
Miguel Palhas
2026-08-24 12:09:52 +01:00
parent 2c293e7f60
commit 4d5cc120d1
3 changed files with 80 additions and 8 deletions
@@ -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"
}
@@ -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"
}
+76 -4
View File
@@ -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;