Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e0d47ded | |||
| d0b35eccd9 | |||
| 08e3c7bce7 |
@@ -46,7 +46,9 @@ pub struct MovieMetadata {
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// TMDB's rating when votes exist; an unrated title is `null`, never `0.0`
|
||||
/// (#156).
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
@@ -68,7 +70,9 @@ pub struct SeriesMetadata {
|
||||
pub status: String,
|
||||
pub poster_path: Option<String>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// TMDB's rating when votes exist; an unrated title is `null`, never `0.0`
|
||||
/// (#156).
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
@@ -366,7 +370,7 @@ mod tests {
|
||||
"genres": [{"id": 18, "name": "Drama"}],
|
||||
"backdrop_path": "/eMhDKZscBd07OLpAeeAyu3N3U8c.jpg",
|
||||
"poster_path": "/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
|
||||
"vote_average": 8.5,
|
||||
"vote_average": 0.0,
|
||||
"vote_count": 2_911,
|
||||
"homepage": "",
|
||||
"status": "Returning Series",
|
||||
@@ -397,6 +401,11 @@ mod tests {
|
||||
|
||||
assert_eq!(body["tmdb_id"], 82_728);
|
||||
assert_eq!(body["tvdb_id"], 392_256);
|
||||
// TMDB reports vote_average: 0 for an unrated title (#156): it leaves
|
||||
// as null, never as a rating of zero. The movie case covers the rated
|
||||
// pass-through.
|
||||
assert_eq!(body["vote_average"], serde_json::Value::Null);
|
||||
assert_eq!(body["vote_count"], 2_911);
|
||||
// TMDB sends a list of episode lengths; the API reports one runtime.
|
||||
assert_eq!(body["runtime"], 55);
|
||||
assert_eq!(body["status"], "Returning Series");
|
||||
|
||||
@@ -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<String>, Option<String>, f64)> {
|
||||
) -> Option<(Option<String>, Option<String>, Option<f64>)> {
|
||||
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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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]
|
||||
async fn metadata_changes_reset_a_title_backoff() {
|
||||
let (_dir, database) = wanted_movie().await;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 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<f64>,
|
||||
pub seasons: Vec<SeasonSummary>,
|
||||
}
|
||||
|
||||
@@ -210,8 +213,8 @@ pub struct Video {
|
||||
/// `append_to_response`, served through the same cache as everything else;
|
||||
/// nothing here is persisted.
|
||||
///
|
||||
/// No float fields are involved in equality except `vote_average`, so this is
|
||||
/// `PartialEq` only.
|
||||
/// No float fields are involved in equality except [`MovieDetail::vote_average`],
|
||||
/// so this is `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovieDetail {
|
||||
pub tmdb_id: u32,
|
||||
@@ -220,7 +223,9 @@ pub struct MovieDetail {
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// TMDB's rating when votes exist; zero is normalised away like
|
||||
/// [`Movie::vote_average`].
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
@@ -241,7 +246,9 @@ pub struct SeriesDetail {
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
/// TMDB's rating when votes exist; zero is normalised away like
|
||||
/// [`Movie::vote_average`].
|
||||
pub vote_average: Option<f64>,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
@@ -372,7 +379,7 @@ impl From<RawSeries> 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 +569,7 @@ impl From<RawMovie> 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 +603,12 @@ fn non_empty(value: Option<String>) -> Option<String> {
|
||||
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)]
|
||||
pub(crate) struct RawMovieDetail {
|
||||
id: u32,
|
||||
@@ -762,7 +775,7 @@ impl From<RawMovieDetail> for MovieDetail {
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
@@ -790,7 +803,7 @@ impl From<RawSeriesDetail> for SeriesDetail {
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_average: rating(raw.vote_average),
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -610,7 +613,7 @@ async fn movie_detail_parses_the_rich_fields() {
|
||||
assert_eq!(movie.genres[0].name, "Science Fiction");
|
||||
assert!(movie.poster_path.is_some());
|
||||
assert!(movie.backdrop_path.is_some());
|
||||
assert!((movie.vote_average - 8.152).abs() < f64::EPSILON);
|
||||
assert_eq!(movie.vote_average, Some(8.152));
|
||||
assert_eq!(movie.vote_count, 6_249);
|
||||
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
|
||||
assert_eq!(movie.status, "Released");
|
||||
|
||||
Reference in New Issue
Block a user