-12
@@ -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"
|
|
||||||
}
|
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
+20
-2
@@ -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"
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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"
|
||||||
}
|
}
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
-12
@@ -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"
|
|
||||||
}
|
|
||||||
+12
@@ -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"
|
||||||
|
}
|
||||||
@@ -310,10 +310,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?;
|
||||||
@@ -323,6 +331,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")),
|
||||||
@@ -979,6 +998,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.
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user