From 409967eef65594f0ca7e559b2a273ace0fa70810 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:34:55 +0100 Subject: [PATCH] feat(daemon): reconcile vanished seasons (#137) --- ...60547c4fbac9959c79a59e2192c5fb448b4bb.json | 20 ++ ...bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json | 12 + ...d521094bc6db7c6bb1e1d5daca4e746c56506.json | 12 + ...055d4e169a4a13d4897d03820557a65ddf61f.json | 12 + crates/arr-daemon/src/series_refresh.rs | 259 +++++++++++++++++- 5 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 .sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json create mode 100644 .sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json create mode 100644 .sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json create mode 100644 .sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json diff --git a/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json b/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json new file mode 100644 index 0000000..df3a631 --- /dev/null +++ b/.sqlx/query-1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id\n WHERE f.owner_kind = 'episode' AND e.season_id = ?\n ) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "1b594ec9db13ac2eacffe98d8cb60547c4fbac9959c79a59e2192c5fb448b4bb" +} diff --git a/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json b/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json new file mode 100644 index 0000000..152c509 --- /dev/null +++ b/.sqlx/query-26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "26e3da3ba98a6bb1bc371922a02bc8faa769b6ae37e2174c6712b4d1b5d4dadc" +} diff --git a/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json b/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json new file mode 100644 index 0000000..ed4bc81 --- /dev/null +++ b/.sqlx/query-b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM seasons WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506" +} diff --git a/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json b/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json new file mode 100644 index 0000000..a177682 --- /dev/null +++ b/.sqlx/query-df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "df409e37c7a6defd3f8960d1750055d4e169a4a13d4897d03820557a65ddf61f" +} diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index 28a9573..00cc10a 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -13,12 +13,15 @@ //! - `upstream_ended` follows TMDB's status, which `ended` is derived from; //! - a missing `tvdb_id` is backfilled (#120). //! -//! Episodes that vanish upstream follow two rules (#122): without a file of -//! their own they are deleted outright, cascading through `episode_releases`; -//! with one they are flagged `vanished` instead, because deleting the row -//! would orphan a real file (`media_files` is polymorphic on its owner). A -//! vanished number that reappears clears its flag again. Renumbering needs no -//! matching of its own — it is just these two rules seen from both ends. +//! Episodes and seasons that vanish upstream follow two rules (#122, #137): +//! without a file of their own they are deleted outright, cascading through +//! `episode_releases`; with one they are flagged `vanished` instead, because +//! deleting the row would orphan a real file (`media_files` is polymorphic on +//! its owner). A vanished number that reappears clears its flag again. +//! Season 0 is exempt — TMDB drops and re-adds it routinely, and #118 already +//! keeps specials out of status, so churning it is noise rather than signal. +//! Renumbering needs no matching of its own — it is just these two rules seen +//! from both ends. //! //! Refresh is idempotent: over unchanged TMDB data only the stamp moves. @@ -125,6 +128,11 @@ impl SeriesRefreshAction { .map(|season| (season.number, (season.id, season.tracked))) .collect::>(); let mut revealed = Vec::new(); + let upstream_seasons = metadata + .seasons + .iter() + .map(|summary| i64::from(summary.number)) + .collect::>(); for summary in &metadata.seasons { if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) { changed |= self @@ -136,6 +144,15 @@ impl SeriesRefreshAction { summary.number, ) .await?; + // The season is back upstream — a TMDB reversal, or a + // renumber seen from the other end. The conflict is over. + let restored = sqlx::query!( + "UPDATE seasons SET vanished = 0, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished != 0", + season_id + ) + .execute(&mut *transaction) + .await?; + changed |= restored.rows_affected() > 0; } else { let detail = self.tmdb.season(tmdb_id, summary.number).await?; revealed.push(RefreshedSeason { @@ -157,6 +174,10 @@ impl SeriesRefreshAction { changed = true; } + changed |= self + .reconcile_vanished_seasons(&mut transaction, &existing, &upstream_seasons) + .await?; + transaction.commit().await?; stamp_refreshed(database, stale.id).await?; Ok(changed.then(|| { @@ -391,6 +412,52 @@ impl SeriesRefreshAction { } Ok(changed) } + + /// #137, one level up from `reconcile_vanished`. Seasons the library + /// knows that TMDB no longer lists have vanished upstream. One with no + /// file on any of its episodes is deleted — the foreign key cascades + /// through its episodes and their `episode_releases` — and one with a + /// file anywhere under it is flagged instead: dropping the rows would + /// orphan a real file, the same trap #122 documents. Season 0 is exempt: + /// TMDB drops and re-adds specials routinely, and §4.2 already keeps them + /// out of derived status, so churning the flag is noise rather than + /// signal. + async fn reconcile_vanished_seasons( + &self, + executor: &mut sqlx::SqliteConnection, + known: &HashMap, + upstream: &HashSet, + ) -> Result { + let mut changed = false; + for (&number, &(season_id, _)) in known { + if number == 0 || upstream.contains(&number) { + continue; + } + let has_file = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM media_files f JOIN episodes e ON e.id = f.owner_id + WHERE f.owner_kind = 'episode' AND e.season_id = ? + ) AS "exists!: bool""#, + season_id + ) + .fetch_one(&mut *executor) + .await?; + if has_file { + sqlx::query!( + "UPDATE seasons SET vanished = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND vanished = 0", + season_id + ) + .execute(&mut *executor) + .await?; + } else { + sqlx::query!("DELETE FROM seasons WHERE id = ?", season_id) + .execute(&mut *executor) + .await?; + } + changed = true; + } + Ok(changed) + } } impl Action for SeriesRefreshAction { @@ -538,6 +605,46 @@ mod tests { }) } + /// The series detail as `mount` serves it, restricted to the given + /// season numbers — a dropped number is how a vanished season presents. + fn series_body_with(status: &str, numbers: &[u32]) -> serde_json::Value { + json!({ + "id": 82_728, + "name": "Bluey", + "original_language": "en", + "first_air_date": "2018-10-01", + "status": status, + "seasons": numbers + .iter() + .map(|number| json!({"season_number": number, "episode_count": 0})) + .collect::>(), + "external_ids": {"tvdb_id": 361_391} + }) + } + + /// Mounts only the season detail endpoints listed; a request for any + /// other season would fail the test loudly. + async fn mount_with(server: &MockServer, status: &str, numbers: &[u32]) { + Mock::given(method("GET")) + .and(path("/tv/82728")) + .respond_with(ResponseTemplate::new(200).set_body_json(series_body_with( + status, numbers, + ))) + .mount(server) + .await; + for (number, body) in [(1u32, season_one_body(&two_episodes())), (2, season_two_body())] { + if !numbers.contains(&number) { + continue; + } + Mock::given(method("GET")) + .and(path(format!("/tv/82728/season/{number}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; + } + } + + /// A TMDB serving one series with two seasons. `reset` between phases of /// a test and remount, counting requests by delta around each phase. async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer { @@ -976,4 +1083,144 @@ mod tests { .unwrap(); assert!(!vanished, "the conflict is over once TMDB lists it again"); } + + async fn season_two_episode(database: &Db) -> i64 { + sqlx::query_scalar( + "SELECT e.id FROM episodes e JOIN seasons s ON s.id = e.season_id + WHERE s.number = 2 AND e.number = 1", + ) + .fetch_one(database.pool()) + .await + .unwrap() + } + + /// #137. TMDB dropped season 2 entirely: with no file under any of its + /// episodes the season is deleted, and its episodes and their stored + /// releases cascade with it. + #[tokio::test] + async fn a_vanished_season_without_files_is_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_release(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1, "a season removal is work worth reporting"); + let seasons: Vec = + sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(seasons, vec![1]); + let episodes: i64 = + sqlx::query_scalar("SELECT count(*) FROM episodes WHERE season_id NOT IN (SELECT id FROM seasons)") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(episodes, 0, "the vanished season's episodes cascade"); + let releases: i64 = sqlx::query_scalar( + "SELECT count(*) FROM episode_releases WHERE episode_id = ?", + ) + .bind(episode) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(releases, 0); + } + + /// #137. With a file anywhere under it the vanished season is never + /// deleted: dropping the rows would orphan a real file. The season is + /// flagged — the same conflict marker an episode gets (#122) — and its + /// episodes and file stay put. + #[tokio::test] + async fn a_vanished_season_with_a_file_is_flagged_not_deleted() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_file(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let (vanished, episodes, files): (i64, i64, i64) = sqlx::query_as( + "SELECT s.vanished, + (SELECT count(*) FROM episodes e WHERE e.season_id = s.id), + (SELECT count(*) FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = ?) + FROM seasons s WHERE s.number = 2", + ) + .bind(episode) + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!( + (vanished, episodes, files), + (1, 1, 1), + "flagged as a conflict, episodes and file intact" + ); + } + + /// Idempotence at season level too: a season TMDB restores clears the + /// flag again, the way a restored episode number does (#122). + #[tokio::test] + async fn a_restored_season_clears_the_vanished_flag() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + let episode = season_two_episode(&database).await; + attach_file(&database, episode).await; + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1]).await; + action(&server).tick(&database).await.unwrap(); + expire_refresh(&database).await; + // TMDB puts the season back where it was. + server.reset().await; + mount(&server, "Returning Series", season_one_body(&two_episodes())).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert_eq!(outcomes.len(), 1); + let vanished: bool = sqlx::query_scalar("SELECT vanished FROM seasons WHERE number = 2") + .fetch_one(database.pool()) + .await + .unwrap(); + assert!(!vanished, "the conflict is over once TMDB lists it again"); + } + + /// §4.2 keeps specials out of derived status, so nothing downstream can + /// notice their absence — and TMDB drops and re-adds season 0 routinely. + /// A vanished season 0 is therefore left alone either way. + #[tokio::test] + async fn a_vanished_season_zero_is_left_alone() { + let (_dir, database) = seeded_series(true).await; + let server = tmdb("Returning Series", season_one_body(&two_episodes())).await; + action(&server).tick(&database).await.unwrap(); + sqlx::query("INSERT INTO seasons (series_id, number) SELECT id, 0 FROM series") + .execute(database.pool()) + .await + .unwrap(); + expire_refresh(&database).await; + server.reset().await; + mount_with(&server, "Returning Series", &[1, 2]).await; + + let outcomes = action(&server).tick(&database).await.unwrap(); + + assert!(outcomes.is_empty(), "churning specials is not work to report"); + let seasons: Vec = + sqlx::query_scalar("SELECT number FROM seasons ORDER BY number") + .fetch_all(database.pool()) + .await + .unwrap(); + assert_eq!(seasons, vec![0, 1, 2]); + } }