From 8478c0f8a9c157ee92f5f3fd53cdb7b8237d7cd9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:12:05 +0100 Subject: [PATCH] feat(db): store poster, backdrop and rating on titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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. --- ...4a78db9b8c277a7e276b2ffb04c8064ce7cf8.json | 12 -- ...7c74f232ec6543787f64efeb53bffa8170113.json | 12 ++ ...1f115f9bf97edbc5ff6621bcbe90d60f5644.json} | 22 ++- ...58ffe11054b836ca53ee31b185b522749331.json} | 6 +- ...e5b9dbabd174fe6d2affbd72872f848ab8090.json | 12 ++ ...94a8cd270a4dedacdfd07bff37c8f455934d9.json | 12 -- ...93728f7acb1eb4b4ec08bd36d9fbc5aed28d4.json | 12 ++ crates/arr-api/src/movies.rs | 90 ++++++++++- crates/arr-api/src/series.rs | 100 ++++++++++-- crates/arr-daemon/src/grab.rs | 144 ++++++++++++++---- crates/arr-daemon/src/series_refresh.rs | 67 +++++++- .../arr-db/migrations/0022_title_artwork.sql | 11 ++ crates/arr-db/src/lib.rs | 32 ++++ crates/arr-meta/src/model.rs | 30 +++- 14 files changed, 484 insertions(+), 78 deletions(-) delete mode 100644 .sqlx/query-369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8.json create mode 100644 .sqlx/query-462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113.json rename .sqlx/{query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json => query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json} (72%) rename .sqlx/{query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json => query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json} (55%) create mode 100644 .sqlx/query-dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090.json delete mode 100644 .sqlx/query-e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9.json create mode 100644 .sqlx/query-f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4.json create mode 100644 crates/arr-db/migrations/0022_title_artwork.sql diff --git a/.sqlx/query-369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8.json b/.sqlx/query-369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8.json deleted file mode 100644 index 8cf652b..0000000 --- a/.sqlx/query-369bc5ff5cf6b84b51bfb0b254a4a78db9b8c277a7e276b2ffb04c8064ce7cf8.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113.json b/.sqlx/query-462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113.json new file mode 100644 index 0000000..c9c1698 --- /dev/null +++ b/.sqlx/query-462039959030ee881bcce2daf9d7c74f232ec6543787f64efeb53bffa8170113.json @@ -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" +} diff --git a/.sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json b/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json similarity index 72% rename from .sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json rename to .sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json index 4fc28c0..c6982bd 100644 --- a/.sqlx/query-f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87.json +++ b/.sqlx/query-6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644.json @@ -1,6 +1,6 @@ { "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": { "columns": [ { @@ -52,6 +52,21 @@ "name": "metadata_refreshed_at", "ordinal": 9, "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": { @@ -67,8 +82,11 @@ false, false, false, + true, + true, + true, true ] }, - "hash": "f75d62778d54b07b26804bb45f88a08ec4257eec9a56fd4181a34c85ccc5ad87" + "hash": "6fb25e97d46957c92475679190d71f115f9bf97edbc5ff6621bcbe90d60f5644" } diff --git a/.sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json b/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json similarity index 55% rename from .sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json rename to .sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json index 25dd49f..b3fa0d0 100644 --- a/.sqlx/query-88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054.json +++ b/.sqlx/query-9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331.json @@ -1,12 +1,12 @@ { "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": { "columns": [], "parameters": { - "Right": 10 + "Right": 13 }, "nullable": [] }, - "hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054" + "hash": "9ce66f0bdb64b26ffad51d5e908f58ffe11054b836ca53ee31b185b522749331" } diff --git a/.sqlx/query-dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090.json b/.sqlx/query-dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090.json new file mode 100644 index 0000000..d4f096d --- /dev/null +++ b/.sqlx/query-dfa8f651301ca99bc7fdbd784ebe5b9dbabd174fe6d2affbd72872f848ab8090.json @@ -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" +} diff --git a/.sqlx/query-e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9.json b/.sqlx/query-e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9.json deleted file mode 100644 index 547cfaf..0000000 --- a/.sqlx/query-e476863873e5574440db796925a94a8cd270a4dedacdfd07bff37c8f455934d9.json +++ /dev/null @@ -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" -} diff --git a/.sqlx/query-f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4.json b/.sqlx/query-f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4.json new file mode 100644 index 0000000..71e81dc --- /dev/null +++ b/.sqlx/query-f8bfa60fa2f328f8daba030616793728f7acb1eb4b4ec08bd36d9fbc5aed28d4.json @@ -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" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 676ee55..970e7f0 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -306,10 +306,18 @@ pub async fn create( let overrides = serde_json::to_string(&input.overrides) .map_err(|error| ApiError::Invalid(error.to_string()))?; 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!( - "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.wanted, input.blocked, overrides + input.wanted, input.blocked, overrides, + poster_path, backdrop_path, vote_average, ) .execute(pool(&state)?) .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, 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)) +} + #[utoipa::path( get, path = "/api/movies/{movie_id}", tag = "movies", params(("movie_id" = i64, Path, description = "Movie row id")), @@ -975,6 +994,73 @@ mod tests { 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::().await.expect("json")["id"] + .as_i64() + .expect("movie id"); + + let (poster, backdrop, vote): (Option, Option, Option) = + 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 /// reaches the API — a file that merely plays must never read as a clean /// match. diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index 2f68088..a7d7a15 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -438,12 +438,19 @@ pub async fn create( let title = input.title.trim(); // §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 - // to the title text query until a refresh fills it in (#121). - let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await; + // to the title text query until a refresh fills it in (#121). The same + // 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!( - "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.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)?) .await?; @@ -453,14 +460,10 @@ pub async fn create( )) } -/// Best effort: `None` when TMDB has no id or cannot be reached. -async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option { +/// Best effort: `None` when TMDB has no such id or cannot be reached. +async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option { let client = tmdb_client(state).ok()?; - let ids = client - .series_external_ids(u32::try_from(tmdb_id).ok()?) - .await - .ok()?; - ids.tvdb_id.map(i64::from) + client.series(u32::try_from(tmdb_id).ok()?).await.ok() } #[utoipa::path( @@ -1347,6 +1350,81 @@ mod tests { 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::().await.expect("json")["id"] + .as_i64() + .expect("series id"); + + let (tvdb_id, poster, backdrop, vote): ( + Option, + Option, + Option, + Option, + ) = 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] async fn series_must_sit_on_a_tv_root() { let (_dir, state, base) = application().await; diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 3f60459..13349aa 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -183,6 +183,50 @@ impl GrabAction { let tmdb_id = u32::try_from(movie.tmdb_id).map_err(|_| GrabError::InvalidTmdbId(movie.id))?; 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 { let title = metadata.title.clone(); let year = metadata.year().map(i64::from); let original_language = @@ -191,43 +235,53 @@ impl GrabAction { // §6.2: the id RSS matching prefers, and the one Torznab movie // searches take. TMDB does not know one for every title. 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 original_language_ref = original_language.as_deref(); let digital_release_ref = digital_release.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!( r#"UPDATE movies 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'), search_attempts = 0, last_searched_at = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? AND ( title IS NOT ? OR year IS NOT ? OR original_language 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, year, original_language_ref, digital_release_ref, imdb_id_ref, + poster_path_ref, + backdrop_path_ref, + vote_average, movie.id, title_ref, year, original_language_ref, digital_release_ref, imdb_id_ref, + poster_path_ref, + backdrop_path_ref, + vote_average, ) .execute(database.pool()) .await? .rows_affected() != 0; - if changed { - tracing::info!( - movie_id = movie.id, - "metadata changed; reset targeted search backoff" - ); - } else { + if !changed { // Still stamp the refresh even when nothing changed, or the TTL // gate above never engages and every tick pays for TMDB again. sqlx::query!( @@ -238,31 +292,7 @@ impl GrabAction { .execute(database.pool()) .await?; } - 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" - ); - } - 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, - )) + Ok(changed) } /// Search every indexer for one title, cache each candidate with its @@ -2417,6 +2447,56 @@ mod tests { 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, Option, Option) = + 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, Option, Option) = + 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] 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 eab1152..ebc5a13 100644 --- a/crates/arr-daemon/src/series_refresh.rs +++ b/crates/arr-daemon/src/series_refresh.rs @@ -74,7 +74,8 @@ impl SeriesRefreshAction { r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, 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 ORDER BY metadata_refreshed_at IS NOT NULL, metadata_refreshed_at, id"# ) @@ -222,6 +223,29 @@ impl SeriesRefreshAction { .await?; 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) } @@ -481,8 +505,11 @@ struct DueSeries { auto_track: bool, upstream_ended: bool, metadata_refreshed_at: Option, + /// §9.6 artwork columns, compared against TMDB's on each refresh. + poster_path: Option, + backdrop_path: Option, + vote_average: Option, } - /// TMDB numbers are unbounded; ours are `u16` (`CHECK (number >= 0)`, /// STRICT). A number past `u16::MAX` cannot match anything real and would /// never be grabbed anyway. @@ -716,6 +743,42 @@ mod tests { .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, Option, Option) = + 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] async fn first_refresh_reveals_seasons_and_applies_auto_track() { let (_dir, database) = seeded_series(true).await; diff --git a/crates/arr-db/migrations/0022_title_artwork.sql b/crates/arr-db/migrations/0022_title_artwork.sql new file mode 100644 index 0000000..66c8f0c --- /dev/null +++ b/crates/arr-db/migrations/0022_title_artwork.sql @@ -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; diff --git a/crates/arr-db/src/lib.rs b/crates/arr-db/src/lib.rs index cdb95cf..0b97cce 100644 --- a/crates/arr-db/src/lib.rs +++ b/crates/arr-db/src/lib.rs @@ -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, Option, Option) = + 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] async fn foreign_keys_are_enforced() { let (_dir, db) = fresh().await; diff --git a/crates/arr-meta/src/model.rs b/crates/arr-meta/src/model.rs index 51f39ea..972d229 100644 --- a/crates/arr-meta/src/model.rs +++ b/crates/arr-meta/src/model.rs @@ -41,7 +41,9 @@ impl MovieSearchResult { } /// 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 { /// TMDB's own id. pub tmdb_id: u32, @@ -77,6 +79,11 @@ pub struct Movie { pub overview: Option, /// Path fragment, not a URL. pub poster_path: Option, + /// 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, } 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 tmdb_id: u32, pub tvdb_id: Option, @@ -124,6 +133,11 @@ pub struct Series { pub status: String, pub overview: Option, pub poster_path: Option, + /// §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, pub seasons: Vec, } @@ -318,6 +332,10 @@ pub(crate) struct RawSeries { #[serde(default)] poster_path: Option, #[serde(default)] + backdrop_path: Option, + #[serde(default)] + vote_average: f64, + #[serde(default)] seasons: Vec, #[serde(default)] external_ids: Option, @@ -353,6 +371,8 @@ impl From for Series { status: raw.status, overview: non_empty(raw.overview), poster_path: non_empty(raw.poster_path), + backdrop_path: non_empty(raw.backdrop_path), + vote_average: raw.vote_average, seasons: raw .seasons .into_iter() @@ -478,6 +498,10 @@ pub(crate) struct RawMovie { #[serde(default)] poster_path: Option, #[serde(default)] + backdrop_path: Option, + #[serde(default)] + vote_average: f64, + #[serde(default)] release_dates: Option, } @@ -537,6 +561,8 @@ impl From for Movie { status: raw.status, overview: non_empty(raw.overview), poster_path: non_empty(raw.poster_path), + backdrop_path: non_empty(raw.backdrop_path), + vote_average: raw.vote_average, } } }