From 08e3c7bce73e81825fcd6d44df24e2dfc0b5a974 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:21:57 +0100 Subject: [PATCH] fix(meta): store unrated titles as NULL, not zero TMDB reports vote_average: 0 where no rating exists (#156). Normalise it to None at the arr-meta edge, like non_empty does for "", and let the Option flow through the daemon refresh and API add paths so the nullable columns from #145 do their job. --- crates/arr-api/src/movies.rs | 4 +- crates/arr-api/src/series.rs | 2 +- crates/arr-daemon/src/grab.rs | 75 +++++++++++++++++++++++++ crates/arr-daemon/src/series_refresh.rs | 2 +- crates/arr-meta/src/model.rs | 21 +++++-- crates/arr-meta/tests/tmdb.rs | 3 + 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index eecd47e..487fc95 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -316,7 +316,7 @@ pub async fn create( let artwork = lookup_movie_artwork(&state, input.tmdb_id).await; let poster_path = artwork.as_ref().and_then(|a| a.0.clone()); let backdrop_path = artwork.as_ref().and_then(|a| a.1.clone()); - let vote_average = artwork.as_ref().map(|a| a.2); + let vote_average = artwork.as_ref().and_then(|a| a.2); let result = sqlx::query!( "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", input.tmdb_id, title, input.year, input.original_language, input.root_id, @@ -336,7 +336,7 @@ pub async fn create( async fn lookup_movie_artwork( state: &AppState, tmdb_id: i64, -) -> Option<(Option, Option, f64)> { +) -> Option<(Option, Option, Option)> { let client = crate::search::tmdb_client(state).ok()?; let movie = client.movie(u32::try_from(tmdb_id).ok()?).await.ok()?; Some((movie.poster_path, movie.backdrop_path, movie.vote_average)) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index a7d7a15..d15b617 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -445,7 +445,7 @@ pub async fn create( let tvdb_id = tmdb_series.as_ref().and_then(|s| s.tvdb_id).map(i64::from); let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone()); let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone()); - let vote_average = tmdb_series.as_ref().map(|s| s.vote_average); + let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average); let result = sqlx::query!( "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 13349aa..7add623 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -2497,6 +2497,81 @@ mod tests { assert_eq!(unchanged, (poster, backdrop, vote)); } + /// TMDB reports `vote_average: 0` for a title nobody has rated — absence, + /// not zero (#156). It must store as NULL, be overwritten when votes + /// arrive, and go back to NULL if they are withdrawn. + #[tokio::test] + async fn metadata_refresh_stores_an_unrated_title_as_null() { + let (_dir, database) = wanted_movie().await; + let indexer = empty_prowlarr().await; + let unrated = || { + RELEASED_METADATA.replace( + r#""original_language": "en","#, + r#""original_language": "en", + "poster_path": "/dune-two.jpg", + "backdrop_path": "/dune-two-wide.jpg", + "vote_average": 0.0,"#, + ) + }; + let rated = || { + RELEASED_METADATA.replace( + r#""original_language": "en","#, + r#""original_language": "en", + "poster_path": "/dune-two.jpg", + "backdrop_path": "/dune-two-wide.jpg", + "vote_average": 8.152,"#, + ) + }; + let (downloader, _fake) = transmission().await; + + action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await) + .tick(&database) + .await + .unwrap(); + let vote: Option = + sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(vote, None); + + // Votes arrive: the NULL is overwritten. + sqlx::query( + "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')", + ) + .execute(database.pool()) + .await + .unwrap(); + action_with_tmdb(&indexer, &downloader, &tmdb(&rated()).await) + .tick(&database) + .await + .unwrap(); + let vote: Option = + sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(vote, Some(8.152)); + + // And withdrawn again. + sqlx::query( + "UPDATE movies SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 hours')", + ) + .execute(database.pool()) + .await + .unwrap(); + action_with_tmdb(&indexer, &downloader, &tmdb(&unrated()).await) + .tick(&database) + .await + .unwrap(); + let vote: Option = + sqlx::query_scalar("SELECT vote_average FROM movies WHERE tmdb_id IS NOT NULL") + .fetch_one(database.pool()) + .await + .unwrap(); + assert_eq!(vote, None); + } + #[tokio::test] async fn metadata_changes_reset_a_title_backoff() { let (_dir, database) = wanted_movie().await; diff --git a/crates/arr-daemon/src/series_refresh.rs b/crates/arr-daemon/src/series_refresh.rs index ebc5a13..1bb5f9f 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -232,7 +232,7 @@ impl SeriesRefreshAction { let backdrop_path_ref = backdrop_path.as_deref(); let artwork_moved = stale.poster_path != poster_path || stale.backdrop_path != backdrop_path; - if artwork_moved || stale.vote_average != Some(vote_average) { + if artwork_moved || stale.vote_average != vote_average { sqlx::query!( "UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 972d229..dc68591 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -82,8 +82,10 @@ pub struct Movie { /// Path fragment, not a URL. §9.6 stores this on the row alongside /// [`Movie::poster_path`] and `vote_average`. pub backdrop_path: Option, - /// TMDB's rating, out of 10. Stored with the artwork (§9.6). - pub vote_average: f64, + /// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the + /// artwork (§9.6). TMDB sends `0` where "no rating yet" is meant, so a + /// zero rating is normalised away here rather than stored as one. + pub vote_average: Option, } impl Movie { @@ -136,8 +138,9 @@ pub struct Series { /// §9.6 stores this on the row alongside `poster_path` and /// [`Series::vote_average`]. pub backdrop_path: Option, - /// TMDB's rating, out of 10. Stored with the artwork (§9.6). - pub vote_average: f64, + /// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the + /// artwork (§9.6); zero is normalised away like [`Movie::vote_average`]. + pub vote_average: Option, pub seasons: Vec, } @@ -372,7 +375,7 @@ impl From for Series { overview: non_empty(raw.overview), poster_path: non_empty(raw.poster_path), backdrop_path: non_empty(raw.backdrop_path), - vote_average: raw.vote_average, + vote_average: rating(raw.vote_average), seasons: raw .seasons .into_iter() @@ -562,7 +565,7 @@ impl From for Movie { overview: non_empty(raw.overview), poster_path: non_empty(raw.poster_path), backdrop_path: non_empty(raw.backdrop_path), - vote_average: raw.vote_average, + vote_average: rating(raw.vote_average), } } } @@ -596,6 +599,12 @@ fn non_empty(value: Option) -> Option { value.filter(|text| !text.is_empty()) } +/// TMDB uses `0` where "no votes yet" is meant, so an unrated title never +/// carries a rating rather than carrying zero. +fn rating(value: f64) -> Option { + (value != 0.0).then_some(value) +} + #[derive(Debug, Deserialize)] pub(crate) struct RawMovieDetail { id: u32, diff --git a/crates/arr-meta/tests/tmdb.rs b/crates/arr-meta/tests/tmdb.rs index e20b4eb..335b8ac 100644 --- a/crates/arr-meta/tests/tmdb.rs +++ b/crates/arr-meta/tests/tmdb.rs @@ -217,6 +217,9 @@ async fn unreleased_movie_has_no_dates_and_no_imdb_id() { assert_eq!(movie.imdb_id, None); assert_eq!(movie.runtime, None); assert!(movie.origin_countries.is_empty()); + // TMDB reports vote_average: 0 for an unrated title — absence, not zero + // (#156), so it is normalised away like any other zero-valued null. + assert_eq!(movie.vote_average, None); assert!(!movie.is_digitally_released(date(2026, 8, 22))); }