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:
@@ -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<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(
|
||||
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::<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
|
||||
/// reaches the API — a file that merely plays must never read as a clean
|
||||
/// match.
|
||||
|
||||
@@ -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<i64> {
|
||||
/// Best effort: `None` when TMDB has no such id or cannot be reached.
|
||||
async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option<arr_meta::Series> {
|
||||
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::<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]
|
||||
async fn series_must_sit_on_a_tv_root() {
|
||||
let (_dir, state, base) = application().await;
|
||||
|
||||
Reference in New Issue
Block a user