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:
Miguel Palhas
2026-08-23 22:45:08 +01:00
parent 30382585ea
commit 5e2bb5dff7
21 changed files with 357 additions and 66 deletions
+47 -8
View File
@@ -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