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.
This commit is contained in:
Miguel Palhas
2026-08-23 22:21:57 +01:00
parent b59e30dbdb
commit 08e3c7bce7
6 changed files with 97 additions and 10 deletions
+2 -2
View File
@@ -316,7 +316,7 @@ pub async fn create(
let artwork = lookup_movie_artwork(&state, input.tmdb_id).await; let artwork = lookup_movie_artwork(&state, input.tmdb_id).await;
let poster_path = artwork.as_ref().and_then(|a| a.0.clone()); 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 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!( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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, 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( async fn lookup_movie_artwork(
state: &AppState, state: &AppState,
tmdb_id: i64, tmdb_id: i64,
) -> Option<(Option<String>, Option<String>, f64)> { ) -> Option<(Option<String>, Option<String>, Option<f64>)> {
let client = crate::search::tmdb_client(state).ok()?; let client = crate::search::tmdb_client(state).ok()?;
let movie = client.movie(u32::try_from(tmdb_id).ok()?).await.ok()?; let movie = client.movie(u32::try_from(tmdb_id).ok()?).await.ok()?;
Some((movie.poster_path, movie.backdrop_path, movie.vote_average)) Some((movie.poster_path, movie.backdrop_path, movie.vote_average))
+1 -1
View File
@@ -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 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 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 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!( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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, input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
+75
View File
@@ -2497,6 +2497,81 @@ mod tests {
assert_eq!(unchanged, (poster, backdrop, vote)); 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<f64> =
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<f64> =
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<f64> =
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] #[tokio::test]
async fn metadata_changes_reset_a_title_backoff() { async fn metadata_changes_reset_a_title_backoff() {
let (_dir, database) = wanted_movie().await; let (_dir, database) = wanted_movie().await;
+1 -1
View File
@@ -232,7 +232,7 @@ impl SeriesRefreshAction {
let backdrop_path_ref = backdrop_path.as_deref(); let backdrop_path_ref = backdrop_path.as_deref();
let artwork_moved = let artwork_moved =
stale.poster_path != poster_path || stale.backdrop_path != backdrop_path; 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!( sqlx::query!(
"UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?, "UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
+15 -6
View File
@@ -82,8 +82,10 @@ pub struct Movie {
/// Path fragment, not a URL. §9.6 stores this on the row alongside /// Path fragment, not a URL. §9.6 stores this on the row alongside
/// [`Movie::poster_path`] and `vote_average`. /// [`Movie::poster_path`] and `vote_average`.
pub backdrop_path: Option<String>, pub backdrop_path: Option<String>,
/// TMDB's rating, out of 10. Stored with the artwork (§9.6). /// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
pub vote_average: f64, /// 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<f64>,
} }
impl Movie { impl Movie {
@@ -136,8 +138,9 @@ pub struct Series {
/// §9.6 stores this on the row alongside `poster_path` and /// §9.6 stores this on the row alongside `poster_path` and
/// [`Series::vote_average`]. /// [`Series::vote_average`].
pub backdrop_path: Option<String>, pub backdrop_path: Option<String>,
/// TMDB's rating, out of 10. Stored with the artwork (§9.6). /// TMDB's rating, out of 10, when TMDB has votes for it. Stored with the
pub vote_average: f64, /// artwork (§9.6); zero is normalised away like [`Movie::vote_average`].
pub vote_average: Option<f64>,
pub seasons: Vec<SeasonSummary>, pub seasons: Vec<SeasonSummary>,
} }
@@ -372,7 +375,7 @@ impl From<RawSeries> for Series {
overview: non_empty(raw.overview), overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path), poster_path: non_empty(raw.poster_path),
backdrop_path: non_empty(raw.backdrop_path), backdrop_path: non_empty(raw.backdrop_path),
vote_average: raw.vote_average, vote_average: rating(raw.vote_average),
seasons: raw seasons: raw
.seasons .seasons
.into_iter() .into_iter()
@@ -562,7 +565,7 @@ impl From<RawMovie> for Movie {
overview: non_empty(raw.overview), overview: non_empty(raw.overview),
poster_path: non_empty(raw.poster_path), poster_path: non_empty(raw.poster_path),
backdrop_path: non_empty(raw.backdrop_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<String>) -> Option<String> {
value.filter(|text| !text.is_empty()) 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<f64> {
(value != 0.0).then_some(value)
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(crate) struct RawMovieDetail { pub(crate) struct RawMovieDetail {
id: u32, id: u32,
+3
View File
@@ -217,6 +217,9 @@ async fn unreleased_movie_has_no_dates_and_no_imdb_id() {
assert_eq!(movie.imdb_id, None); assert_eq!(movie.imdb_id, None);
assert_eq!(movie.runtime, None); assert_eq!(movie.runtime, None);
assert!(movie.origin_countries.is_empty()); 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))); assert!(!movie.is_digitally_released(date(2026, 8, 22)));
} }