-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",
|
||||
"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"
|
||||
}
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
+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)
|
||||
.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?;
|
||||
@@ -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(
|
||||
get, path = "/api/movies/{movie_id}", tag = "movies",
|
||||
params(("movie_id" = i64, Path, description = "Movie row id")),
|
||||
@@ -979,6 +998,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;
|
||||
|
||||
+112
-32
@@ -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<bool, GrabError> {
|
||||
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<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]
|
||||
async fn metadata_changes_reset_a_title_backoff() {
|
||||
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,
|
||||
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<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)`,
|
||||
/// 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<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]
|
||||
async fn first_refresh_reveals_seasons_and_applies_auto_track() {
|
||||
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]
|
||||
async fn foreign_keys_are_enforced() {
|
||||
let (_dir, db) = fresh().await;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Path fragment, not a URL.
|
||||
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 {
|
||||
@@ -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<u32>,
|
||||
@@ -124,6 +133,11 @@ pub struct Series {
|
||||
pub status: String,
|
||||
pub overview: 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>,
|
||||
}
|
||||
|
||||
@@ -318,6 +332,10 @@ pub(crate) struct RawSeries {
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
seasons: Vec<RawSeasonSummary>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
@@ -353,6 +371,8 @@ impl From<RawSeries> 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<String>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
vote_average: f64,
|
||||
#[serde(default)]
|
||||
release_dates: Option<RawReleaseDates>,
|
||||
}
|
||||
|
||||
@@ -537,6 +561,8 @@ impl From<RawMovie> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user