feat(daemon): reconcile vanished seasons (#137)

This commit is contained in:
Miguel Palhas
2026-08-23 19:34:55 +01:00
parent 943f18cdf5
commit 409967eef6
5 changed files with 309 additions and 6 deletions
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM seasons WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
}
@@ -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"
}
+253 -6
View File
@@ -13,12 +13,15 @@
//! - `upstream_ended` follows TMDB's status, which `ended` is derived from; //! - `upstream_ended` follows TMDB's status, which `ended` is derived from;
//! - a missing `tvdb_id` is backfilled (#120). //! - a missing `tvdb_id` is backfilled (#120).
//! //!
//! Episodes that vanish upstream follow two rules (#122): without a file of //! Episodes and seasons that vanish upstream follow two rules (#122, #137):
//! their own they are deleted outright, cascading through `episode_releases`; //! without a file of their own they are deleted outright, cascading through
//! with one they are flagged `vanished` instead, because deleting the row //! `episode_releases`; with one they are flagged `vanished` instead, because
//! would orphan a real file (`media_files` is polymorphic on its owner). A //! deleting the row would orphan a real file (`media_files` is polymorphic on
//! vanished number that reappears clears its flag again. Renumbering needs no //! its owner). A vanished number that reappears clears its flag again.
//! matching of its own — it is just these two rules seen from both ends. //! 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. //! 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))) .map(|season| (season.number, (season.id, season.tracked)))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let mut revealed = Vec::new(); let mut revealed = Vec::new();
let upstream_seasons = metadata
.seasons
.iter()
.map(|summary| i64::from(summary.number))
.collect::<HashSet<_>>();
for summary in &metadata.seasons { for summary in &metadata.seasons {
if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) { if let Some(&(season_id, tracked)) = existing.get(&i64::from(summary.number)) {
changed |= self changed |= self
@@ -136,6 +144,15 @@ impl SeriesRefreshAction {
summary.number, summary.number,
) )
.await?; .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 { } else {
let detail = self.tmdb.season(tmdb_id, summary.number).await?; let detail = self.tmdb.season(tmdb_id, summary.number).await?;
revealed.push(RefreshedSeason { revealed.push(RefreshedSeason {
@@ -157,6 +174,10 @@ impl SeriesRefreshAction {
changed = true; changed = true;
} }
changed |= self
.reconcile_vanished_seasons(&mut transaction, &existing, &upstream_seasons)
.await?;
transaction.commit().await?; transaction.commit().await?;
stamp_refreshed(database, stale.id).await?; stamp_refreshed(database, stale.id).await?;
Ok(changed.then(|| { Ok(changed.then(|| {
@@ -391,6 +412,52 @@ impl SeriesRefreshAction {
} }
Ok(changed) 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<i64, (i64, bool)>,
upstream: &HashSet<i64>,
) -> Result<bool, RefreshError> {
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 { 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::<Vec<_>>(),
"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 TMDB serving one series with two seasons. `reset` between phases of
/// a test and remount, counting requests by delta around each phase. /// a test and remount, counting requests by delta around each phase.
async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer { async fn tmdb(status: &str, season_one: serde_json::Value) -> MockServer {
@@ -976,4 +1083,144 @@ mod tests {
.unwrap(); .unwrap();
assert!(!vanished, "the conflict is over once TMDB lists it again"); 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<i64> =
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<i64> =
sqlx::query_scalar("SELECT number FROM seasons ORDER BY number")
.fetch_all(database.pool())
.await
.unwrap();
assert_eq!(seasons, vec![0, 1, 2]);
}
} }