feat(db): store poster, backdrop and rating on titles

§9.6 keeps rich detail out of the database except for the three fields
pure-SQL views need. Adds poster_path, backdrop_path and vote_average to
movies and series, written by the daily metadata refresh in both lanes
and filled at add time from the TMDB response the create flows already
fetch.
This commit is contained in:
Miguel Palhas
2026-08-23 22:12:05 +01:00
parent 9bd037d1b6
commit 8478c0f8a9
14 changed files with 484 additions and 78 deletions
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE movies\n SET title = ?, year = ?, original_language = ?, digital_release = ?,\n imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,\n metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n search_attempts = 0, last_searched_at = NULL,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? AND (\n title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?\n OR digital_release IS NOT ? OR imdb_id IS NOT ?\n OR poster_path IS NOT ? OR backdrop_path IS NOT ?\n OR vote_average IS NOT ?\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 17
},
"nullable": []
},
"hash": "462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id", "query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", tvdb_id,\n title AS \"title!: String\", year, original_language,\n root_id AS \"root_id!: i64\", auto_track AS \"auto_track!: bool\",\n upstream_ended AS \"upstream_ended!: bool\", metadata_refreshed_at,\n poster_path, backdrop_path, vote_average\n FROM series\n ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -52,6 +52,21 @@
"name": "metadata_refreshed_at", "name": "metadata_refreshed_at",
"ordinal": 9, "ordinal": 9,
"type_info": "Text" "type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "backdrop_path",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "vote_average",
"ordinal": 12,
"type_info": "Float"
} }
], ],
"parameters": { "parameters": {
@@ -67,8 +82,11 @@
false, false,
false, false,
false, false,
true,
true,
true,
true true
] ]
}, },
"hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87" "hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644"
} }
@@ -1,12 +1,12 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Right": 10 "Right": 13
}, },
"nullable": [] "nullable": []
}, },
"hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054" "hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4"
}
+88 -2
View File
@@ -306,10 +306,18 @@ pub async fn create(
let overrides = serde_json::to_string(&input.overrides) let overrides = serde_json::to_string(&input.overrides)
.map_err(|error| ApiError::Invalid(error.to_string()))?; .map_err(|error| ApiError::Invalid(error.to_string()))?;
let title = input.title.trim(); let title = input.title.trim();
// §9.6: the three stored artwork fields are filled from TMDB here so a
// title added today has a poster before tomorrow's refresh. Best effort:
// a TMDB outage must not block an add.
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 result = sqlx::query!( let result = sqlx::query!(
"INSERT INTO movies (tmdb_id, title, year, original_language, root_id, wanted, blocked, overrides) 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,
input.wanted, input.blocked, overrides input.wanted, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
) )
.execute(pool(&state)?) .execute(pool(&state)?)
.await?; .await?;
@@ -319,6 +327,17 @@ pub async fn create(
)) ))
} }
/// §9.6 artwork for a new movie row, straight off the detail response.
/// `None` when TMDB is not configured or cannot be reached.
async fn lookup_movie_artwork(
state: &AppState,
tmdb_id: i64,
) -> Option<(Option<String>, Option<String>, 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))
}
#[utoipa::path( #[utoipa::path(
get, path = "/api/movies/{movie_id}", tag = "movies", get, path = "/api/movies/{movie_id}", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie row id")), params(("movie_id" = i64, Path, description = "Movie row id")),
@@ -975,6 +994,73 @@ mod tests {
response.json().await.expect("movie json") response.json().await.expect("movie json")
} }
/// §9.6: the three stored artwork fields come off the detail response at
/// add time, so a title added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_movie_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/movie/693134"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 693_134,
"title": "Dune Part Two",
"poster_path": "/dune-two.jpg",
"backdrop_path": "/dune-two-wide.jpg",
"vote_average": 8.152
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/movies"))
.json(&serde_json::json!({
"tmdb_id": 693_134, "title": "Dune Part Two",
"original_language": "en", "root_id": 2
}))
.send()
.await
.expect("create movie");
assert_eq!(response.status(), StatusCode::CREATED);
let movie_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("movie id");
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM movies WHERE id = ?",
)
.bind(movie_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("movie row");
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
}
/// §5.7: a soft-failed import is imported and waived, and the waiver /// §5.7: a soft-failed import is imported and waived, and the waiver
/// reaches the API — a file that merely plays must never read as a clean /// reaches the API — a file that merely plays must never read as a clean
/// match. /// match.
+89 -11
View File
@@ -438,12 +438,19 @@ pub async fn create(
let title = input.title.trim(); let title = input.title.trim();
// §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not // §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not
// knowing one must not block adding the series — the search falls back // knowing one must not block adding the series — the search falls back
// to the title text query until a refresh fills it in (#121). // to the title text query until a refresh fills it in (#121). The same
let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await; // response carries §9.6's stored artwork fields, so a series added today
// has a poster before tomorrow's refresh. Best effort either way.
let tmdb_series = lookup_tmdb_series(&state, input.tmdb_id).await;
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 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) 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,
input.auto_track, input.upstream_ended, input.blocked, overrides input.auto_track, input.upstream_ended, input.blocked, overrides,
poster_path, backdrop_path, vote_average,
) )
.execute(pool(&state)?) .execute(pool(&state)?)
.await?; .await?;
@@ -453,14 +460,10 @@ pub async fn create(
)) ))
} }
/// Best effort: `None` when TMDB has no id or cannot be reached. /// Best effort: `None` when TMDB has no such id or cannot be reached.
async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option<i64> { async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option<arr_meta::Series> {
let client = tmdb_client(state).ok()?; let client = tmdb_client(state).ok()?;
let ids = client client.series(u32::try_from(tmdb_id).ok()?).await.ok()
.series_external_ids(u32::try_from(tmdb_id).ok()?)
.await
.ok()?;
ids.tvdb_id.map(i64::from)
} }
#[utoipa::path( #[utoipa::path(
@@ -1347,6 +1350,81 @@ mod tests {
response.json().await.expect("season json") response.json().await.expect("season json")
} }
/// §9.6: the three stored artwork fields come off the series detail
/// response at add time — the same call that used to fetch only the
/// TVDB id — so a series added today has a poster before tomorrow's
/// refresh.
#[tokio::test]
async fn creating_a_series_stores_artwork_from_tmdb() {
let tmdb = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/tv/82728"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": 82_728,
"name": "Bluey",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"external_ids": {"tvdb_id": 361_391}
})),
)
.mount(&tmdb)
.await;
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url(tmdb.uri())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey",
"original_language": "en", "root_id": 3
}))
.send()
.await
.expect("create series");
assert_eq!(response.status(), StatusCode::CREATED);
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("series id");
let (tvdb_id, poster, backdrop, vote): (
Option<i64>,
Option<String>,
Option<String>,
Option<f64>,
) = sqlx::query_as(
"SELECT tvdb_id, poster_path, backdrop_path, vote_average FROM series WHERE id = ?",
)
.bind(series_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("series row");
assert_eq!(tvdb_id, Some(361_391));
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
#[tokio::test] #[tokio::test]
async fn series_must_sit_on_a_tv_root() { async fn series_must_sit_on_a_tv_root() {
let (_dir, state, base) = application().await; let (_dir, state, base) = application().await;
+112 -32
View File
@@ -183,6 +183,50 @@ impl GrabAction {
let tmdb_id = let tmdb_id =
u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?; u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?;
let metadata = tmdb.movie(tmdb_id).await?; let metadata = tmdb.movie(tmdb_id).await?;
let changed = self.store_metadata(database, &movie, &metadata).await?;
if changed {
tracing::info!(
movie_id = movie.id,
"metadata changed; reset targeted search backoff"
);
}
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive());
if !released {
tracing::debug!(
movie_id = movie.id,
"digital release has not happened; skipping targeted search"
);
}
let title = metadata.title.clone();
Ok((
PendingMovie {
id: movie.id,
tmdb_id: movie.tmdb_id,
title,
year: metadata.year().map(i64::from),
original_language: (!metadata.original_language.is_empty())
.then_some(metadata.original_language.clone()),
search_attempts: if changed { 0 } else { movie.search_attempts },
last_searched_at: if changed {
None
} else {
movie.last_searched_at
},
digital_release: metadata.digital_release.map(|date| date.to_string()),
metadata_refreshed_at: None,
},
released,
))
}
/// Write one refresh's fields to the row, guarded so unchanged data moves
/// nothing. Returns whether anything did.
async fn store_metadata(
&self,
database: &Db,
movie: &PendingMovie,
metadata: &arr_meta::Movie,
) -> Result<bool, GrabError> {
let title = metadata.title.clone(); let title = metadata.title.clone();
let year = metadata.year().map(i64::from); let year = metadata.year().map(i64::from);
let original_language = let original_language =
@@ -191,43 +235,53 @@ impl GrabAction {
// §6.2: the id RSS matching prefers, and the one Torznab movie // §6.2: the id RSS matching prefers, and the one Torznab movie
// searches take. TMDB does not know one for every title. // searches take. TMDB does not know one for every title.
let imdb_id = metadata.imdb_id.clone(); let imdb_id = metadata.imdb_id.clone();
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let title_ref = title.as_str(); let title_ref = title.as_str();
let original_language_ref = original_language.as_deref(); let original_language_ref = original_language.as_deref();
let digital_release_ref = digital_release.as_deref(); let digital_release_ref = digital_release.as_deref();
let imdb_id_ref = imdb_id.as_deref(); let imdb_id_ref = imdb_id.as_deref();
let poster_path_ref = poster_path.as_deref();
let backdrop_path_ref = backdrop_path.as_deref();
let changed = sqlx::query!( let changed = sqlx::query!(
r#"UPDATE movies r#"UPDATE movies
SET title = ?, year = ?, original_language = ?, digital_release = ?, SET title = ?, year = ?, original_language = ?, digital_release = ?,
imdb_id = ?, imdb_id = ?, poster_path = ?, backdrop_path = ?, vote_average = ?,
metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
search_attempts = 0, last_searched_at = NULL, search_attempts = 0, last_searched_at = NULL,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ? AND ( WHERE id = ? AND (
title IS NOT ? OR year IS NOT ? OR original_language IS NOT ? title IS NOT ? OR year IS NOT ? OR original_language IS NOT ?
OR digital_release IS NOT ? OR imdb_id IS NOT ? OR digital_release IS NOT ? OR imdb_id IS NOT ?
OR poster_path IS NOT ? OR backdrop_path IS NOT ?
OR vote_average IS NOT ?
)"#, )"#,
title_ref, title_ref,
year, year,
original_language_ref, original_language_ref,
digital_release_ref, digital_release_ref,
imdb_id_ref, imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
movie.id, movie.id,
title_ref, title_ref,
year, year,
original_language_ref, original_language_ref,
digital_release_ref, digital_release_ref,
imdb_id_ref, imdb_id_ref,
poster_path_ref,
backdrop_path_ref,
vote_average,
) )
.execute(database.pool()) .execute(database.pool())
.await? .await?
.rows_affected() .rows_affected()
!= 0; != 0;
if changed { if !changed {
tracing::info!(
movie_id = movie.id,
"metadata changed; reset targeted search backoff"
);
} else {
// Still stamp the refresh even when nothing changed, or the TTL // Still stamp the refresh even when nothing changed, or the TTL
// gate above never engages and every tick pays for TMDB again. // gate above never engages and every tick pays for TMDB again.
sqlx::query!( sqlx::query!(
@@ -238,31 +292,7 @@ impl GrabAction {
.execute(database.pool()) .execute(database.pool())
.await?; .await?;
} }
let released = metadata.is_digitally_released(chrono::Utc::now().date_naive()); Ok(changed)
if !released {
tracing::debug!(
movie_id = movie.id,
"digital release has not happened; skipping targeted search"
);
}
Ok((
PendingMovie {
id: movie.id,
tmdb_id: movie.tmdb_id,
title,
year,
original_language,
search_attempts: if changed { 0 } else { movie.search_attempts },
last_searched_at: if changed {
None
} else {
movie.last_searched_at
},
digital_release,
metadata_refreshed_at: None,
},
released,
))
} }
/// Search every indexer for one title, cache each candidate with its /// Search every indexer for one title, cache each candidate with its
@@ -2417,6 +2447,56 @@ mod tests {
assert_eq!(targeted_searches(&indexer).await, 3); assert_eq!(targeted_searches(&indexer).await, 3);
} }
/// §9.6: the daily refresh also writes the three stored artwork fields,
/// so pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn metadata_refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = wanted_movie().await;
let indexer = empty_prowlarr().await;
let artwork_metadata = 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 metadata = tmdb(&artwork_metadata).await;
let (downloader, _fake) = transmission().await;
action_with_tmdb(&indexer, &downloader, &metadata)
.tick(&database)
.await
.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/dune-two.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-two-wide.jpg"));
assert_eq!(vote, Some(8.152));
// Idempotent: an unchanged refresh neither rewrites nor re-reports,
// same as the fields above it.
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, &metadata)
.tick(&database)
.await
.unwrap();
let unchanged: (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(unchanged, (poster, backdrop, vote));
}
#[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;
+65 -2
View File
@@ -74,7 +74,8 @@ impl SeriesRefreshAction {
r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id,
title AS "title!: String", year, original_language, title AS "title!: String", year, original_language,
root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", root_id AS "root_id!: i64", auto_track AS "auto_track!: bool",
upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at upstream_ended AS "upstream_ended!: bool", metadata_refreshed_at,
poster_path, backdrop_path, vote_average
FROM series FROM series
ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"#
) )
@@ -222,6 +223,29 @@ impl SeriesRefreshAction {
.await?; .await?;
changed = true; changed = true;
} }
// §9.6: these three are the exception to "rich detail is not
// persisted" — pure-SQL views render artwork without a TMDB call.
let poster_path = metadata.poster_path.clone();
let backdrop_path = metadata.backdrop_path.clone();
let vote_average = metadata.vote_average;
let poster_path_ref = poster_path.as_deref();
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) {
sqlx::query!(
"UPDATE series SET poster_path = ?, backdrop_path = ?, vote_average = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE id = ?",
poster_path_ref,
backdrop_path_ref,
vote_average,
stale.id
)
.execute(&mut *executor)
.await?;
changed = true;
}
Ok(changed) Ok(changed)
} }
@@ -481,8 +505,11 @@ struct DueSeries {
auto_track: bool, auto_track: bool,
upstream_ended: bool, upstream_ended: bool,
metadata_refreshed_at: Option<String>, metadata_refreshed_at: Option<String>,
/// §9.6 artwork columns, compared against TMDB's on each refresh.
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f64>,
} }
/// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, /// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`,
/// STRICT). A number past `u16::MAX` cannot match anything real and would /// STRICT). A number past `u16::MAX` cannot match anything real and would
/// never be grabbed anyway. /// never be grabbed anyway.
@@ -716,6 +743,42 @@ mod tests {
.unwrap(); .unwrap();
} }
/// §9.6: the daily refresh writes the three stored artwork fields, so
/// pure-SQL views render a poster without a TMDB call per row.
#[tokio::test]
async fn refresh_writes_the_stored_artwork_fields() {
let (_dir, database) = seeded_series(true).await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/tv/82728"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": 82_728,
"name": "Bluey",
"original_language": "en",
"first_air_date": "2018-10-01",
"status": "Returning Series",
"poster_path": "/bluey.jpg",
"backdrop_path": "/bluey-wide.jpg",
"vote_average": 8.417,
"seasons": []
})))
.mount(&server)
.await;
action(&server).tick(&database).await.unwrap();
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as(
"SELECT poster_path, backdrop_path, vote_average FROM series WHERE tmdb_id = 82728",
)
.fetch_one(database.pool())
.await
.unwrap();
assert_eq!(poster.as_deref(), Some("/bluey.jpg"));
assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg"));
assert_eq!(vote, Some(8.417));
}
#[tokio::test] #[tokio::test]
async fn first_refresh_reveals_seasons_and_applies_auto_track() { async fn first_refresh_reveals_seasons_and_applies_auto_track() {
let (_dir, database) = seeded_series(true).await; let (_dir, database) = seeded_series(true).await;
@@ -0,0 +1,11 @@
-- §9.6: the three fields rich detail keeps out of the database are the ones
-- pure-SQL views need — the in-library half of unified search (§9.2) and the
-- library grid would otherwise cost one TMDB call per row. Nullable, no
-- default: a title added before its first metadata refresh has no artwork
-- yet, and TMDB itself has entries with no poster.
ALTER TABLE movies ADD COLUMN poster_path TEXT;
ALTER TABLE movies ADD COLUMN backdrop_path TEXT;
ALTER TABLE movies ADD COLUMN vote_average REAL;
ALTER TABLE series ADD COLUMN poster_path TEXT;
ALTER TABLE series ADD COLUMN backdrop_path TEXT;
ALTER TABLE series ADD COLUMN vote_average REAL;
+32
View File
@@ -228,6 +228,38 @@ mod tests {
} }
} }
/// §9.6: the stored artwork columns are nullable, and `REAL` is a valid
/// STRICT type — a rating must not be squeezed into an integer.
#[tokio::test]
async fn title_artwork_columns_are_nullable_and_take_reals() {
let (_dir, db) = fresh().await;
sqlx::query(
"INSERT INTO movies (tmdb_id, title, root_id)
SELECT 1, 'No Artwork Yet', id FROM roots WHERE kind = 'movie' LIMIT 1",
)
.execute(db.pool())
.await
.expect("row without artwork");
sqlx::query(
"UPDATE movies SET poster_path = '/dune.jpg', backdrop_path = '/dune-wide.jpg',
vote_average = 8.152
WHERE tmdb_id = 1",
)
.execute(db.pool())
.await
.expect("artwork write");
let (poster, backdrop, vote): (Option<String>, Option<String>, Option<f64>) =
sqlx::query_as("SELECT poster_path, backdrop_path, vote_average FROM movies")
.fetch_one(db.pool())
.await
.expect("movie row");
assert_eq!(poster.as_deref(), Some("/dune.jpg"));
assert_eq!(backdrop.as_deref(), Some("/dune-wide.jpg"));
assert_eq!(vote, Some(8.152));
}
#[tokio::test] #[tokio::test]
async fn foreign_keys_are_enforced() { async fn foreign_keys_are_enforced() {
let (_dir, db) = fresh().await; let (_dir, db) = fresh().await;
+28 -2
View File
@@ -41,7 +41,9 @@ impl MovieSearchResult {
} }
/// A movie as TMDB describes it, reduced to what this project uses. /// A movie as TMDB describes it, reduced to what this project uses.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ///
/// `vote_average` is the one float, so this is `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Movie { pub struct Movie {
/// TMDB's own id. /// TMDB's own id.
pub tmdb_id: u32, pub tmdb_id: u32,
@@ -77,6 +79,11 @@ pub struct Movie {
pub overview: Option<String>, pub overview: Option<String>,
/// Path fragment, not a URL. /// Path fragment, not a URL.
pub poster_path: Option<String>, pub poster_path: Option<String>,
/// 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,
} }
impl Movie { impl Movie {
@@ -114,7 +121,9 @@ impl SeriesSearchResult {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] /// TMDB's slice for series. `vote_average` is the one float, so this is
/// `PartialEq` only.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Series { pub struct Series {
pub tmdb_id: u32, pub tmdb_id: u32,
pub tvdb_id: Option<u32>, pub tvdb_id: Option<u32>,
@@ -124,6 +133,11 @@ pub struct Series {
pub status: String, pub status: String,
pub overview: Option<String>, pub overview: Option<String>,
pub poster_path: Option<String>, pub poster_path: Option<String>,
/// §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,
pub seasons: Vec<SeasonSummary>, pub seasons: Vec<SeasonSummary>,
} }
@@ -318,6 +332,10 @@ pub(crate) struct RawSeries {
#[serde(default)] #[serde(default)]
poster_path: Option<String>, poster_path: Option<String>,
#[serde(default)] #[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
seasons: Vec<RawSeasonSummary>, seasons: Vec<RawSeasonSummary>,
#[serde(default)] #[serde(default)]
external_ids: Option<RawExternalIds>, external_ids: Option<RawExternalIds>,
@@ -353,6 +371,8 @@ impl From<RawSeries> for Series {
status: raw.status, status: raw.status,
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),
vote_average: raw.vote_average,
seasons: raw seasons: raw
.seasons .seasons
.into_iter() .into_iter()
@@ -478,6 +498,10 @@ pub(crate) struct RawMovie {
#[serde(default)] #[serde(default)]
poster_path: Option<String>, poster_path: Option<String>,
#[serde(default)] #[serde(default)]
backdrop_path: Option<String>,
#[serde(default)]
vote_average: f64,
#[serde(default)]
release_dates: Option<RawReleaseDates>, release_dates: Option<RawReleaseDates>,
} }
@@ -537,6 +561,8 @@ impl From<RawMovie> for Movie {
status: raw.status, status: raw.status,
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),
vote_average: raw.vote_average,
} }
} }
} }