feat(api): carry poster and rating through search and lists
Widen the unified search so both §9.2 halves can render a thumbnail and a rating: TMDB results gain vote_average/vote_count from the search bodies, library results read the #145 artwork columns, and episodes take their series' art. GET /api/movies and /api/series gain the same two fields for the #151 grid. Mirrors in web updated.
This commit is contained in:
@@ -59,6 +59,10 @@ pub struct LibrarySeries {
|
||||
pub original_language: Option<String>,
|
||||
pub root_id: i64,
|
||||
pub blocked: bool,
|
||||
/// Stored artwork (§9.6), so a row renders without a TMDB call.
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -70,6 +74,11 @@ pub struct LibraryEpisode {
|
||||
pub tag: String,
|
||||
/// The episode title — what the search matched on.
|
||||
pub title: String,
|
||||
/// The series' poster — an episode has no artwork of its own worth
|
||||
/// showing at row size.
|
||||
pub poster_path: Option<String>,
|
||||
/// The series' rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
|
||||
@@ -91,6 +100,10 @@ pub struct TmdbMovie {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
@@ -108,6 +121,10 @@ pub struct TmdbSeries {
|
||||
pub year: Option<i32>,
|
||||
pub overview: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
pub vote_average: Option<f64>,
|
||||
/// How many votes the rating rests on.
|
||||
pub vote_count: u32,
|
||||
}
|
||||
|
||||
/// One release's score, kept as its terms so the UI can explain a ranking
|
||||
@@ -184,13 +201,13 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
.collect()
|
||||
} else {
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY title, year, id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
.into_iter()
|
||||
.map(LibraryResult::Movie)
|
||||
@@ -204,10 +221,10 @@ pub async fn search(
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.map_err(|_| ApiError::Invalid("invalid TMDB id".into()))?;
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ? OR NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tmdb_id, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
} else {
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE s.title NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, s.year, s.id"#, tokens)
|
||||
.fetch_all(database.pool()).await?
|
||||
};
|
||||
library.extend(series_rows.into_iter().map(LibraryResult::Series));
|
||||
@@ -215,7 +232,7 @@ pub async fn search(
|
||||
// §9.2 names the TV case explicitly: `bluey hospital` finds the episode.
|
||||
// Tokens may split across the series and episode titles, so the haystack
|
||||
// is both joined and every token must land somewhere in it.
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
let episode_rows = sqlx::query_as!(LibraryEpisode, r#"SELECT e.id AS "episode_id!: i64", s.id AS "series_id!: i64", s.title AS "series_title!: String", printf('S%02dE%02d', se.number, e.number) AS "tag!: String", e.title AS "title!: String", s.poster_path, s.vote_average FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE NOT EXISTS (SELECT 1 FROM json_each(?) token WHERE (s.title || ' ' || e.title) NOT LIKE '%' || token.value || '%' ESCAPE '\') ORDER BY s.title, se.number, e.number, e.id"#, tokens)
|
||||
.fetch_all(database.pool())
|
||||
.await?;
|
||||
library.extend(episode_rows.into_iter().map(LibraryResult::Episode));
|
||||
@@ -227,7 +244,7 @@ pub async fn search(
|
||||
match result {
|
||||
TmdbResult::Movie(movie) => {
|
||||
let tmdb_id = i64::from(movie.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
|
||||
if let Some(found) = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at, poster_path, vote_average, (SELECT f.waiver FROM media_files f WHERE f.owner_kind = 'movie' AND f.owner_id = movies.id AND f.waiver IS NOT NULL ORDER BY f.id LIMIT 1) AS "waiver?: serde_json::Value" FROM movies WHERE tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Movie(found));
|
||||
@@ -235,7 +252,7 @@ pub async fn search(
|
||||
}
|
||||
TmdbResult::Series(series) => {
|
||||
let tmdb_id = i64::from(series.tmdb_id);
|
||||
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool" FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
if let Some(found) = sqlx::query_as!(LibrarySeries, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.blocked AS "blocked!: bool", s.poster_path, s.vote_average FROM series s WHERE s.tmdb_id = ?"#, tmdb_id)
|
||||
.fetch_optional(database.pool()).await?
|
||||
{
|
||||
library.push(LibraryResult::Series(found));
|
||||
@@ -291,6 +308,8 @@ async fn search_tmdb(
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
})]
|
||||
}
|
||||
Err(arr_meta::Error::NotFound { .. }) => Vec::new(),
|
||||
@@ -558,6 +577,8 @@ impl From<arr_meta::MovieSearchResult> for TmdbMovie {
|
||||
year,
|
||||
overview: movie.overview,
|
||||
poster_path: movie.poster_path,
|
||||
vote_average: movie.vote_average,
|
||||
vote_count: movie.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,6 +593,8 @@ impl From<arr_meta::SeriesSearchResult> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +609,8 @@ impl From<arr_meta::Series> for TmdbSeries {
|
||||
year,
|
||||
overview: series.overview,
|
||||
poster_path: series.poster_path,
|
||||
vote_average: series.vote_average,
|
||||
vote_count: series.vote_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,7 +841,8 @@ mod tests {
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({"results":[{
|
||||
"id": 693_134, "title": "Dune: Part Two", "original_title": "Dune: Part Two",
|
||||
"original_language": "en", "release_date": "2024-02-27"
|
||||
"original_language": "en", "release_date": "2024-02-27",
|
||||
"poster_path": "/dune-two.jpg", "vote_average": 8.1, "vote_count": 5000
|
||||
}]})),
|
||||
)
|
||||
.mount(&tmdb)
|
||||
@@ -843,6 +869,19 @@ mod tests {
|
||||
assert_eq!(response["library"][0]["title"], "Dune");
|
||||
assert_eq!(response["tmdb"][0]["kind"], "movie");
|
||||
assert_eq!(response["tmdb"][0]["tmdb_id"], 693_134);
|
||||
// §9.6: both halves carry artwork and a rating, read from stored
|
||||
// columns and TMDB's search body — no upstream call per row.
|
||||
assert_eq!(
|
||||
response["library"][0]["poster_path"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(
|
||||
response["library"][0]["vote_average"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(response["tmdb"][0]["poster_path"], "/dune-two.jpg");
|
||||
assert_eq!(response["tmdb"][0]["vote_average"], 8.1);
|
||||
assert_eq!(response["tmdb"][0]["vote_count"], 5000);
|
||||
}
|
||||
|
||||
/// §9.2: the in-library set matches series titles and episode titles, so
|
||||
|
||||
Reference in New Issue
Block a user