feat(daemon): reconcile vanished seasons (#137)
This commit is contained in:
+20
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM seasons WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "b4fc90238a74fb6d156684ec29ad521094bc6db7c6bb1e1d5daca4e746c56506"
|
||||||
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user