Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80a63ea2e1 | |||
| 9bd037d1b6 | |||
| cbb21418a4 | |||
| 689487bf82 | |||
| 2e4e1e7547 | |||
| 1acdcaca23 | |||
| 26dd0dc5d3 | |||
| 877dbae7cb | |||
| cf36a4302c | |||
| 06a68372e6 |
@@ -485,6 +485,11 @@ which skips to the manual-grab flow.
|
||||
There is never a moment where the user has to know whether they are searching or
|
||||
adding.
|
||||
|
||||
Result rows are enriched: a poster thumbnail and a rating, under the same rules
|
||||
as title detail (§9.6) — images hotlinked from path fragments, the rating being
|
||||
TMDB's `vote_average` with its `vote_count`. A trailer chip renders per row and
|
||||
resolves only when clicked (§9.6).
|
||||
|
||||
### 9.3 Manual search results
|
||||
|
||||
Radarr's manual search is unusable because the raw release name is the dominant
|
||||
@@ -540,6 +545,46 @@ notifying on everything and being muted within a week.
|
||||
|
||||
Not notified: grabs, searches, downloads starting or finishing, soft fails.
|
||||
|
||||
### 9.6 Title detail
|
||||
|
||||
One detail surface per kind: `/movies/{id}` and `/series/{id}` (#129). For a
|
||||
movie the release deck becomes a section of the page, and `/movies/{id}/releases`
|
||||
keeps resolving; series keeps its season-and-episode shape.
|
||||
|
||||
**TMDB is the only metadata source.** The rating shown anywhere is TMDB's
|
||||
`vote_average` with its `vote_count`. No OMDb, no IMDb or Rotten Tomatoes
|
||||
scores — each would need a second upstream, a second key and a second thing
|
||||
that can be down.
|
||||
|
||||
**Images are hotlinked** from `image.tmdb.org`. The API returns TMDB path
|
||||
fragments, never URLs; the browser composes the URL and chooses the size. No
|
||||
image proxy and no image cache in the service.
|
||||
|
||||
**Rich detail is not persisted.** It is served through arr-meta's existing
|
||||
24-hour response cache. The single exception is `poster_path`, `backdrop_path`
|
||||
and `vote_average`, stored on `movies` and `series` and written by the daily
|
||||
metadata refresh (§8), so library views render without a TMDB call.
|
||||
|
||||
**Cast** is the top 10 billed — profile photo, actor name, character name —
|
||||
linking out to that person's TMDB page. This does not contradict §2: nothing
|
||||
about a person is stored, tracked or searched on. The non-goal is following
|
||||
people, not naming them.
|
||||
|
||||
**External links** are TMDB always, IMDb for movies, TVDB for series — all from
|
||||
ids the app already holds — plus a Rotten Tomatoes *search* link, which is a
|
||||
query URL, not a resolved title page.
|
||||
|
||||
**Trailers resolve on click.** TMDB's search responses carry no videos, so a
|
||||
trailer key costs a detail call. Rendering one chip per title and resolving the
|
||||
one clicked keeps that cost at one call, and the 24h cache makes a repeat free.
|
||||
|
||||
**Library view.** A poster grid by default with a list toggle; the list keeps
|
||||
the derived-status columns §4.2 built it around.
|
||||
|
||||
Out of scope here, because they are the adjacent scope most likely to creep:
|
||||
watch providers, recommendations or similar titles, collections, person pages
|
||||
inside the app, review text.
|
||||
|
||||
## 10. Persistence
|
||||
|
||||
SQLite via `sqlx`, compile-time-checked queries, migrations in `arr-db`.
|
||||
|
||||
@@ -14,6 +14,7 @@ mod roots;
|
||||
mod search;
|
||||
mod series;
|
||||
mod state;
|
||||
mod trailer;
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
@@ -39,6 +40,7 @@ pub use series::{
|
||||
pub use state::{
|
||||
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
|
||||
};
|
||||
pub use trailer::{Trailer, TrailerKind};
|
||||
|
||||
/// Where the generated document is served, and where `just gen-client` reads
|
||||
/// it back from when it is fetched rather than dumped from the binary.
|
||||
@@ -99,6 +101,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(owners::get, owners::update, owners::delete))
|
||||
.routes(routes!(search::search))
|
||||
.routes(routes!(search::releases))
|
||||
.routes(routes!(trailer::trailer))
|
||||
.routes(routes!(roots::list, roots::create))
|
||||
.routes(routes!(roots::get, roots::update, roots::delete))
|
||||
.routes(routes!(policies::list, policies::create))
|
||||
@@ -326,6 +329,7 @@ mod tests {
|
||||
"post",
|
||||
),
|
||||
("/api/queues/attention", "get"),
|
||||
("/api/trailer", "get"),
|
||||
("/api/series", "get"),
|
||||
("/api/policies", "get"),
|
||||
("/api/policies", "post"),
|
||||
|
||||
@@ -149,6 +149,9 @@ pub enum ApiError {
|
||||
OwnerNotFound,
|
||||
PolicyNotFound,
|
||||
RootNotFound,
|
||||
/// The §9.6 chip outcome: the title exists upstream but has no trailer.
|
||||
/// Ordinary, so it must stay distinguishable from an upstream failure.
|
||||
NoTrailer,
|
||||
Conflict(String),
|
||||
Invalid(String),
|
||||
Unavailable,
|
||||
@@ -169,6 +172,7 @@ impl IntoResponse for ApiError {
|
||||
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
|
||||
Self::PolicyNotFound => (StatusCode::NOT_FOUND, "policy not found".to_string()),
|
||||
Self::RootNotFound => (StatusCode::NOT_FOUND, "root not found".to_string()),
|
||||
Self::NoTrailer => (StatusCode::NOT_FOUND, "no trailer".to_string()),
|
||||
Self::Conflict(error) => (StatusCode::CONFLICT, error),
|
||||
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
|
||||
Self::Unavailable => (
|
||||
|
||||
@@ -652,7 +652,7 @@ pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiE
|
||||
.map_err(|_| ApiError::Unavailable)
|
||||
}
|
||||
|
||||
fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
pub(crate) fn upstream_error(error: &arr_meta::Error) -> ApiError {
|
||||
match error {
|
||||
arr_meta::Error::NotFound { .. } => ApiError::NotFound,
|
||||
_ => ApiError::Unavailable,
|
||||
|
||||
@@ -107,7 +107,11 @@ pub struct Season {
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Episode {
|
||||
pub id: i64,
|
||||
/// The owning series — the episode deck route needs it without walking
|
||||
/// seasons first.
|
||||
pub series_id: i64,
|
||||
pub season_id: i64,
|
||||
pub season_number: i64,
|
||||
pub number: i64,
|
||||
pub title: String,
|
||||
pub air_date: Option<String>,
|
||||
@@ -660,7 +664,9 @@ async fn load_seasons(state: &AppState, series_id: i64) -> Result<Vec<Season>, A
|
||||
.filter(|episode| episode.season_id == season.id)
|
||||
.map(|episode| Episode {
|
||||
id: episode.id,
|
||||
series_id,
|
||||
season_id: episode.season_id,
|
||||
season_number: episode.season_number,
|
||||
number: episode.number,
|
||||
title: episode.title.clone(),
|
||||
air_date: episode.air_date.clone(),
|
||||
@@ -890,7 +896,9 @@ async fn load_episode(state: &AppState, id: i64) -> Result<Episode, ApiError> {
|
||||
.ok_or(ApiError::EpisodeNotFound)?;
|
||||
Ok(Episode {
|
||||
id: row.id,
|
||||
series_id: row.series_id,
|
||||
season_id: row.season_id,
|
||||
season_number: row.season_number,
|
||||
number: row.number,
|
||||
title: row.title,
|
||||
air_date: row.air_date,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::movies::{ApiError, ErrorBody};
|
||||
use crate::search::{tmdb_client, upstream_error};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct TrailerQuery {
|
||||
kind: TrailerKind,
|
||||
tmdb_id: u32,
|
||||
}
|
||||
|
||||
/// Which namespace the TMDB id names. Movie and series ids are independent
|
||||
/// numbering spaces at TMDB.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TrailerKind {
|
||||
Movie,
|
||||
Tv,
|
||||
}
|
||||
|
||||
/// The one trailer worth showing (§9.6), resolved on click rather than
|
||||
/// prefetched: TMDB's search responses carry no videos.
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Trailer {
|
||||
pub youtube_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/trailer", tag = "search", params(TrailerQuery),
|
||||
responses(
|
||||
(status = 200, body = Trailer),
|
||||
(status = 404, body = ErrorBody),
|
||||
(status = 422, body = ErrorBody),
|
||||
(status = 500, body = ErrorBody),
|
||||
(status = 503, body = ErrorBody)
|
||||
)
|
||||
)]
|
||||
pub async fn trailer(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<TrailerQuery>,
|
||||
) -> Result<Json<Trailer>, ApiError> {
|
||||
let tmdb = tmdb_client(&state)?;
|
||||
let video = match query.kind {
|
||||
TrailerKind::Movie => tmdb.movie_videos(query.tmdb_id).await,
|
||||
TrailerKind::Tv => tmdb.series_videos(query.tmdb_id).await,
|
||||
}
|
||||
.map_err(|error| upstream_error(&error))?;
|
||||
match video {
|
||||
Some(video) => Ok(Json(Trailer {
|
||||
youtube_key: video.key,
|
||||
name: video.name,
|
||||
})),
|
||||
None => Err(ApiError::NoTrailer),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{router, Upstreams};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// Serve the app against a mocked TMDB. No database: the handler never
|
||||
/// touches one, because the chip addresses titles that have no local row.
|
||||
async fn application(tmdb: &MockServer) -> String {
|
||||
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("tmdb-key".into())),
|
||||
)
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_movie_trailer_resolves_through_the_videos_call() {
|
||||
let tmdb = MockServer::start().await;
|
||||
// One request, videos only — §9.6 keeps the click cost at one call.
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/693134/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 693_134,
|
||||
"results": [
|
||||
{"key": "fan_edit", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Fan Trailer", "official": false},
|
||||
{"key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer",
|
||||
"name": "Official Trailer", "official": true}
|
||||
]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "Way9Dexny3w");
|
||||
assert_eq!(response["name"], "Official Trailer");
|
||||
tmdb.verify().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_series_trailer_resolves_through_the_same_rule() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/tv/82728/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 82_728,
|
||||
"results": [
|
||||
{"key": "abc123", "site": "YouTube", "type": "Teaser",
|
||||
"name": "Series Teaser", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response: serde_json::Value =
|
||||
reqwest::get(format!("{base}/api/trailer?kind=tv&tmdb_id=82728"))
|
||||
.await
|
||||
.expect("request")
|
||||
.json()
|
||||
.await
|
||||
.expect("json");
|
||||
assert_eq!(response["youtube_key"], "abc123");
|
||||
assert_eq!(response["name"], "Series Teaser");
|
||||
}
|
||||
|
||||
/// A title TMDB knows but has no `YouTube` trailer for is an ordinary
|
||||
/// outcome: the browser renders "no trailer", so 404, never a 5xx.
|
||||
#[tokio::test]
|
||||
async fn a_title_without_a_trailer_is_a_forty_forty() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"id": 1,
|
||||
"results": [
|
||||
{"key": "clip", "site": "Vimeo", "type": "Clip",
|
||||
"name": "A Clip", "official": true}
|
||||
]
|
||||
})))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 404);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["error"], "no trailer");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upstream_outage_is_not_mistaken_for_no_trailer() {
|
||||
let tmdb = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&tmdb)
|
||||
.await;
|
||||
let base = application(&tmdb).await;
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=1"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_tmdb_key_behaves_like_the_rest_of_the_search_surface() {
|
||||
let state = AppState::new(Upstreams::new(
|
||||
"http://127.0.0.1:1".into(),
|
||||
"http://127.0.0.1:1".into(),
|
||||
))
|
||||
.expect("state");
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let address = listener.local_addr().expect("address");
|
||||
tokio::spawn(async move { axum::serve(listener, router(state)).await.expect("serve") });
|
||||
let base = format!("http://{address}");
|
||||
|
||||
let response = reqwest::get(format!("{base}/api/trailer?kind=movie&tmdb_id=693134"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status(), 503);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ use serde::de::DeserializeOwned;
|
||||
use crate::cache::Cache;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{
|
||||
ExternalIds, FindResults, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie,
|
||||
RawSearchPage, RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
|
||||
select_trailer, ExternalIds, FindResults, Movie, MovieDetail, MovieSearchResult,
|
||||
RawExternalIds, RawFindPage, RawMovie, RawMovieDetail, RawSearchPage, RawSeason, RawSeries,
|
||||
RawSeriesDetail, RawSeriesSearchPage, RawVideoList, Season, Series, SeriesDetail,
|
||||
SeriesSearchResult, Video,
|
||||
};
|
||||
|
||||
/// TMDB's v3 API root.
|
||||
@@ -163,6 +165,65 @@ impl TmdbClient {
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one movie's §9.6 page.
|
||||
///
|
||||
/// One HTTP call: credits, videos and external ids come back appended to
|
||||
/// the same response. Served through the same cache as [`Self::movie`];
|
||||
/// nothing here is persisted (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_detail(&self, tmdb_id: u32) -> Result<MovieDetail> {
|
||||
let path = format!("movie/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawMovieDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// Rich detail for one series' §9.6 page.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_detail(&self, tmdb_id: u32) -> Result<SeriesDetail> {
|
||||
let path = format!("tv/{tmdb_id}");
|
||||
let params = [(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids".to_owned(),
|
||||
)];
|
||||
let raw: RawSeriesDetail = self.get_json(&path, ¶ms).await?;
|
||||
Ok(raw.into())
|
||||
}
|
||||
|
||||
/// The chosen trailer for a movie, fetching only the videos list.
|
||||
///
|
||||
/// #144's search-row chip resolves through this, where a full detail
|
||||
/// response would be waste (§9.6).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn movie_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("movie/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// The chosen trailer for a series, fetching only the videos list.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
|
||||
pub async fn series_videos(&self, tmdb_id: u32) -> Result<Option<Video>> {
|
||||
let path = format!("tv/{tmdb_id}/videos");
|
||||
let raw: RawVideoList = self.get_json(&path, &[]).await?;
|
||||
Ok(select_trailer(&raw.into_videos()))
|
||||
}
|
||||
|
||||
/// External ids for one TV series, of which the TVDB id is the one this
|
||||
/// project needs (§6.1).
|
||||
///
|
||||
|
||||
@@ -24,6 +24,6 @@ mod model;
|
||||
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{
|
||||
Episode, ExternalIds, FindResults, Movie, MovieSearchResult, Season, Series,
|
||||
SeriesSearchResult, UNTITLED_EPISODE,
|
||||
CastMember, Episode, ExternalIds, FindResults, Genre, Movie, MovieDetail, MovieSearchResult,
|
||||
Season, Series, SeriesDetail, SeriesSearchResult, Video, UNTITLED_EPISODE,
|
||||
};
|
||||
|
||||
@@ -157,6 +157,107 @@ pub struct Episode {
|
||||
pub air_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Cast is truncated to the top 10 billed, in the crate, so no caller has to
|
||||
/// remember to (§9.6).
|
||||
const CAST_LIMIT: usize = 10;
|
||||
|
||||
/// A genre as a detail response carries it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Genre {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One of the top-billed cast members on a detail page. `profile_path` is a
|
||||
/// path fragment — §9.6 hotlinks images and the browser composes the URL.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CastMember {
|
||||
/// The person's TMDB id, for the link out to tmdb.org (§9.6).
|
||||
pub tmdb_id: u32,
|
||||
pub name: String,
|
||||
pub character: String,
|
||||
pub profile_path: Option<String>,
|
||||
pub order: u32,
|
||||
}
|
||||
|
||||
/// One entry of a title's video list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Video {
|
||||
pub key: String,
|
||||
/// `YouTube`, `Vimeo`, … Only `YouTube` ever becomes a trailer (§9.6).
|
||||
pub site: String,
|
||||
/// TMDB calls this field `type`: `Trailer`, `Teaser`, `Clip`, …
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub official: bool,
|
||||
}
|
||||
|
||||
/// Rich movie detail for the §9.6 page. One upstream request via
|
||||
/// `append_to_response`, served through the same cache as everything else;
|
||||
/// nothing here is persisted.
|
||||
///
|
||||
/// No float fields are involved in equality except `vote_average`, so this is
|
||||
/// `PartialEq` only.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MovieDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
pub runtime: Option<u32>,
|
||||
/// §9.6 links out to `IMDb` for movies.
|
||||
pub imdb_id: Option<String>,
|
||||
/// Top [`CAST_LIMIT`] billed, ordered by TMDB's own cast order.
|
||||
pub cast: Vec<CastMember>,
|
||||
/// The one trailer worth showing, chosen by the §9.6 rule.
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SeriesDetail {
|
||||
pub tmdb_id: u32,
|
||||
pub overview: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub genres: Vec<Genre>,
|
||||
pub backdrop_path: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub vote_average: f64,
|
||||
pub vote_count: u32,
|
||||
pub homepage: Option<String>,
|
||||
pub status: String,
|
||||
/// Episode length in minutes; TMDB sends a list per episode, this takes
|
||||
/// the first.
|
||||
pub episode_runtime: Option<u32>,
|
||||
/// §9.6 links out to TVDB for series.
|
||||
pub tvdb_id: Option<u32>,
|
||||
pub cast: Vec<CastMember>,
|
||||
pub trailer: Option<Video>,
|
||||
}
|
||||
|
||||
/// The trailer rule from §9.6, shared by the detail calls and the videos-only
|
||||
/// calls #144 reads. Preference order: an official `YouTube` trailer, then any
|
||||
/// `YouTube` trailer, then any `YouTube` teaser, then nothing. Within a tier
|
||||
/// the first match wins, which keeps the result deterministic for a given
|
||||
/// response.
|
||||
#[must_use]
|
||||
pub(crate) fn select_trailer(videos: &[Video]) -> Option<Video> {
|
||||
let pick = |want: &dyn Fn(&Video) -> bool| {
|
||||
videos
|
||||
.iter()
|
||||
.find(|video| video.site == "YouTube" && want(video))
|
||||
.cloned()
|
||||
};
|
||||
pick(&|video| video.kind == "Trailer" && video.official)
|
||||
.or_else(|| pick(&|video| video.kind == "Trailer"))
|
||||
.or_else(|| pick(&|video| video.kind == "Teaser"))
|
||||
}
|
||||
|
||||
// --- TMDB wire types -------------------------------------------------------
|
||||
//
|
||||
// Private on purpose. TMDB's field names stop here.
|
||||
@@ -468,3 +569,209 @@ fn parse_datetime(raw: &str) -> Option<NaiveDate> {
|
||||
fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawMovieDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
runtime: Option<u32>,
|
||||
#[serde(default)]
|
||||
imdb_id: Option<String>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawSeriesDetail {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
tagline: Option<String>,
|
||||
#[serde(default)]
|
||||
overview: Option<String>,
|
||||
#[serde(default)]
|
||||
genres: Vec<RawGenre>,
|
||||
#[serde(default)]
|
||||
backdrop_path: Option<String>,
|
||||
#[serde(default)]
|
||||
poster_path: Option<String>,
|
||||
vote_average: f64,
|
||||
vote_count: u32,
|
||||
#[serde(default)]
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
episode_run_time: Vec<u32>,
|
||||
#[serde(default)]
|
||||
external_ids: Option<RawExternalIds>,
|
||||
#[serde(default)]
|
||||
credits: Option<RawCredits>,
|
||||
#[serde(default)]
|
||||
videos: Option<RawVideoList>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawGenre {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCredits {
|
||||
#[serde(default)]
|
||||
cast: Vec<RawCastMember>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawCastMember {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
character: String,
|
||||
#[serde(default)]
|
||||
profile_path: Option<String>,
|
||||
order: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct RawVideoList {
|
||||
#[serde(default)]
|
||||
results: Vec<RawVideo>,
|
||||
}
|
||||
|
||||
impl RawVideoList {
|
||||
pub(crate) fn into_videos(self) -> Vec<Video> {
|
||||
self.results.into_iter().map(Into::into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawVideo {
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
site: String,
|
||||
#[serde(rename = "type", default)]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
official: bool,
|
||||
}
|
||||
|
||||
impl From<RawVideo> for Video {
|
||||
fn from(raw: RawVideo) -> Self {
|
||||
Self {
|
||||
key: raw.key,
|
||||
site: raw.site,
|
||||
kind: raw.kind,
|
||||
name: raw.name,
|
||||
official: raw.official,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cast_from(raw: Option<RawCredits>) -> Vec<CastMember> {
|
||||
let mut cast: Vec<CastMember> = raw
|
||||
.map(|credits| {
|
||||
credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|member| CastMember {
|
||||
tmdb_id: member.id,
|
||||
name: member.name,
|
||||
character: member.character,
|
||||
profile_path: non_empty(member.profile_path),
|
||||
order: member.order,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// TMDB's own ordering is by `order`; sorting makes the truncation hold
|
||||
// even if a fixture or future API revision sends them shuffled.
|
||||
cast.sort_by_key(|member| member.order);
|
||||
cast.truncate(CAST_LIMIT);
|
||||
cast
|
||||
}
|
||||
|
||||
fn trailer_from(raw: Option<RawVideoList>) -> Option<Video> {
|
||||
let videos: Vec<Video> = raw
|
||||
.map(|list| list.results.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default();
|
||||
select_trailer(&videos)
|
||||
}
|
||||
|
||||
impl From<RawMovieDetail> for MovieDetail {
|
||||
fn from(raw: RawMovieDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
runtime: raw.runtime,
|
||||
imdb_id: non_empty(raw.imdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawSeriesDetail> for SeriesDetail {
|
||||
fn from(raw: RawSeriesDetail) -> Self {
|
||||
Self {
|
||||
tmdb_id: raw.id,
|
||||
overview: non_empty(raw.overview),
|
||||
tagline: non_empty(raw.tagline),
|
||||
genres: raw
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|genre| Genre {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
})
|
||||
.collect(),
|
||||
backdrop_path: non_empty(raw.backdrop_path),
|
||||
poster_path: non_empty(raw.poster_path),
|
||||
vote_average: raw.vote_average,
|
||||
vote_count: raw.vote_count,
|
||||
homepage: non_empty(raw.homepage),
|
||||
status: raw.status,
|
||||
episode_runtime: raw.episode_run_time.into_iter().next(),
|
||||
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
|
||||
cast: cast_from(raw.credits),
|
||||
trailer: trailer_from(raw.videos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": 693134,
|
||||
"imdb_id": "tt15239678",
|
||||
"title": "Dune: Part Two",
|
||||
"original_title": "Dune: Part Two",
|
||||
"tagline": "Long live the fighters.",
|
||||
"overview": "Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.",
|
||||
"status": "Released",
|
||||
"runtime": 167,
|
||||
"homepage": "https://www.dunemovie.com",
|
||||
"vote_average": 8.152,
|
||||
"vote_count": 6249,
|
||||
"poster_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"backdrop_path": "/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
|
||||
"genres": [
|
||||
{ "id": 878, "name": "Science Fiction" },
|
||||
{ "id": 12, "name": "Adventure" }
|
||||
],
|
||||
"credits": {
|
||||
"cast": [
|
||||
{ "id": 5530, "name": "Timothée Chalamet", "character": "Paul Atreides", "profile_path": "/x3UkVAsKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 0 },
|
||||
{ "id": 3559977, "name": "Zendaya", "character": "Chani", "profile_path": "/xaWu0DjKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 1 },
|
||||
{ "id": 37614, "name": "Rebecca Ferguson", "character": "Lady Jessica", "profile_path": "/fPM5vM7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 2 },
|
||||
{ "id": 1110844, "name": "Austin Butler", "character": "Feyd-Rautha Harkonnen", "profile_path": null, "order": 3 },
|
||||
{ "id": 22451, "name": "Josh Brolin", "character": "Gurney Halleck", "profile_path": "/qR11vJKKyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 4 },
|
||||
{ "id": 593015, "name": "Florence Pugh", "character": "Princess Irulan", "profile_path": "/mP55vB7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 5 },
|
||||
{ "id": 1244949, "name": "Dave Bautista", "character": "Glossu Rabban Harkonnen", "profile_path": null, "order": 6 },
|
||||
{ "id": 11220, "name": "Christopher Walken", "character": "Emperor Shaddam IV", "profile_path": "/wP55vC7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 7 },
|
||||
{ "id": 38673, "name": "Léa Seydoux", "character": "Lady Margot Fenring", "profile_path": null, "order": 8 },
|
||||
{ "id": 2880644, "name": "Souheila Yacoub", "character": "Shishakli", "profile_path": "/nP55vD7KyFQ0Y2fr1mz4R6dYYXK.jpg", "order": 9 },
|
||||
{ "id": 10952, "name": "Stellan Skarsgård", "character": "Baron Vladimir Harkonnen", "profile_path": null, "order": 10 },
|
||||
{ "id": 33940, "name": "Charlotte Rampling", "character": "Reverend Mother Gaius Helen Mohiam", "profile_path": null, "order": 11 }
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"results": [
|
||||
{ "key": "n9xhJrPXop4", "site": "YouTube", "type": "Teaser", "name": "Official Teaser", "official": true },
|
||||
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Dune Part Two Fan Trailer", "official": false },
|
||||
{ "key": "clip_vimeo_id", "site": "Vimeo", "type": "Trailer", "name": "Trailer (Vimeo)", "official": true },
|
||||
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
|
||||
]
|
||||
},
|
||||
"external_ids": {
|
||||
"imdb_id": "tt15239678"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 693134,
|
||||
"results": [
|
||||
{ "key": "fanmade_trailer", "site": "YouTube", "type": "Trailer", "name": "Fan Trailer", "official": false },
|
||||
{ "key": "Way9Dexny3w", "site": "YouTube", "type": "Trailer", "name": "Official Trailer", "official": true }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": 82728,
|
||||
"name": "Bluey",
|
||||
"original_name": "Bluey",
|
||||
"tagline": "",
|
||||
"overview": "The slice-of-life adventures of an Australian cattle dog called Bluey and her family.",
|
||||
"status": "Returning Series",
|
||||
"homepage": "https://www.blueytv.com",
|
||||
"vote_average": 8.417,
|
||||
"vote_count": 118,
|
||||
"poster_path": "/58Pm1HTKHefFBCPVAVXOI0cDdIg.jpg",
|
||||
"backdrop_path": "/9K4mLtNKhEfFBCPVAVXOI0cDdIg.jpg",
|
||||
"first_air_date": "2018-10-01",
|
||||
"episode_run_time": [7],
|
||||
"genres": [
|
||||
{ "id": 16, "name": "Animation" },
|
||||
{ "id": 10751, "name": "Family" },
|
||||
{ "id": 10759, "name": "Action & Adventure" }
|
||||
],
|
||||
"credits": {
|
||||
"cast": [
|
||||
{ "id": 2134777, "name": "Melanie Zanetti", "character": "Chilli Heeler (voice)", "profile_path": "/zAnetti.jpg", "order": 1 },
|
||||
{ "id": 1760828, "name": "David McCormack", "character": "Bandit Heeler (voice)", "profile_path": "/dMcCormack.jpg", "order": 0 }
|
||||
]
|
||||
},
|
||||
"videos": {
|
||||
"results": [
|
||||
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
|
||||
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
|
||||
]
|
||||
},
|
||||
"external_ids": {
|
||||
"imdb_id": "tt7614372",
|
||||
"tvdb_id": 361391
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 82728,
|
||||
"results": [
|
||||
{ "key": "bluey_clip", "site": "YouTube", "type": "Clip", "name": "Clip: Keepy Uppy", "official": true },
|
||||
{ "key": "bluey_teaser", "site": "YouTube", "type": "Teaser", "name": "Series Teaser", "official": false }
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,10 @@ const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only
|
||||
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
|
||||
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
|
||||
const FIND_IMDB_SERIES: &str = include_str!("fixtures/find_imdb_series.json");
|
||||
const MOVIE_DETAIL_DUNE: &str = include_str!("fixtures/movie_detail_dune.json");
|
||||
const MOVIE_VIDEOS_DUNE: &str = include_str!("fixtures/movie_videos_dune.json");
|
||||
const SERIES_DETAIL_BLUEY: &str = include_str!("fixtures/series_detail_bluey.json");
|
||||
const SERIES_VIDEOS_BLUEY: &str = include_str!("fixtures/series_videos_bluey.json");
|
||||
|
||||
fn client(server: &MockServer) -> TmdbClient {
|
||||
TmdbClient::builder("test-key")
|
||||
@@ -556,3 +560,249 @@ async fn series_without_a_tvdb_id_maps_to_none() {
|
||||
|
||||
assert_eq!(ids.tvdb_id, None);
|
||||
}
|
||||
|
||||
// --- detail (#143, §9.6) ----------------------------------------------------
|
||||
|
||||
/// A whole detail page costs one upstream request: credits, videos and
|
||||
/// external ids ride along in the same response.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_asks_for_credits_videos_and_external_ids_in_one_call() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.and(query_param(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn movie_detail_parses_the_rich_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(movie.tmdb_id, 693_134);
|
||||
assert_eq!(
|
||||
movie.overview.as_deref(),
|
||||
Some("Paul Atreides unites with Chani and the Fremen while seeking revenge against the conspirators who destroyed his family.")
|
||||
);
|
||||
assert_eq!(movie.tagline.as_deref(), Some("Long live the fighters."));
|
||||
assert_eq!(movie.genres.len(), 2);
|
||||
assert_eq!(movie.genres[0].id, 878);
|
||||
assert_eq!(movie.genres[0].name, "Science Fiction");
|
||||
assert!(movie.poster_path.is_some());
|
||||
assert!(movie.backdrop_path.is_some());
|
||||
assert!((movie.vote_average - 8.152).abs() < f64::EPSILON);
|
||||
assert_eq!(movie.vote_count, 6_249);
|
||||
assert_eq!(movie.homepage.as_deref(), Some("https://www.dunemovie.com"));
|
||||
assert_eq!(movie.status, "Released");
|
||||
assert_eq!(movie.runtime, Some(167));
|
||||
assert_eq!(movie.imdb_id.as_deref(), Some("tt15239678"));
|
||||
}
|
||||
|
||||
/// §9.6: cast is the top 10 billed, truncated in the crate so no caller has to
|
||||
/// remember to. The fixture carries 12 entries; only the first 10 by `order`
|
||||
/// survive.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_cast_is_truncated_to_ten_by_order() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(movie.cast.len(), 10);
|
||||
assert_eq!(movie.cast[0].tmdb_id, 5_530);
|
||||
assert_eq!(movie.cast[0].name, "Timothée Chalamet");
|
||||
assert_eq!(movie.cast[0].character, "Paul Atreides");
|
||||
assert_eq!(movie.cast[0].order, 0);
|
||||
// Truncation keeps the lowest `order` values, not the first rows sent.
|
||||
assert_eq!(movie.cast[9].name, "Souheila Yacoub");
|
||||
assert!(movie.cast.iter().all(|member| member.tmdb_id != 10_952));
|
||||
}
|
||||
|
||||
/// §9.6: an official `YouTube` trailer, then any `YouTube` trailer, then a
|
||||
/// `YouTube` teaser. The fixture puts a fan trailer and a Vimeo entry before
|
||||
/// the official one; neither wins.
|
||||
#[tokio::test]
|
||||
async fn movie_detail_picks_the_official_youtube_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let movie = client(&server)
|
||||
.movie_detail(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
let trailer = movie.trailer.expect("a trailer is chosen");
|
||||
assert_eq!(trailer.key, "Way9Dexny3w");
|
||||
assert_eq!(trailer.site, "YouTube");
|
||||
assert_eq!(trailer.kind, "Trailer");
|
||||
assert!(trailer.official);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_detail_parses_the_rich_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728"))
|
||||
.and(query_param(
|
||||
"append_to_response",
|
||||
"credits,videos,external_ids",
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let series = client(&server)
|
||||
.series_detail(82_728)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(series.tmdb_id, 82_728);
|
||||
assert_eq!(series.status, "Returning Series");
|
||||
assert_eq!(series.episode_runtime, Some(7));
|
||||
assert_eq!(series.tvdb_id, Some(361_391));
|
||||
assert!(series.tagline.is_none());
|
||||
assert_eq!(series.genres[0].name, "Animation");
|
||||
// Cast comes out in TMDB's `order`, not in the order the rows arrived.
|
||||
assert_eq!(series.cast[0].name, "David McCormack");
|
||||
// No trailer exists for this series, so the teaser tier is what fires.
|
||||
let trailer = series.trailer.expect("the teaser is chosen");
|
||||
assert_eq!(trailer.key, "bluey_teaser");
|
||||
}
|
||||
|
||||
/// Detail goes through the same cache as everything else — two calls, one
|
||||
/// request.
|
||||
#[tokio::test]
|
||||
async fn movie_and_series_detail_are_served_from_the_cache() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_DETAIL_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_DETAIL_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tmdb = client(&server);
|
||||
let _ = tmdb.movie_detail(693_134).await.expect("succeeds");
|
||||
let _ = tmdb.movie_detail(693_134).await.expect("cached");
|
||||
let _ = tmdb.series_detail(82_728).await.expect("succeeds");
|
||||
let _ = tmdb.series_detail(82_728).await.expect("cached");
|
||||
}
|
||||
|
||||
/// #144's search-row chip resolves through the videos-only call: one request,
|
||||
/// no full detail response.
|
||||
#[tokio::test]
|
||||
async fn movie_videos_fetches_only_the_videos_endpoint() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/693134/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(MOVIE_VIDEOS_DUNE))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(693_134)
|
||||
.await
|
||||
.expect("lookup succeeds")
|
||||
.expect("an official trailer is chosen");
|
||||
|
||||
assert_eq!(trailer.key, "Way9Dexny3w");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn series_videos_applies_the_same_selection_rule() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/tv/82728/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_VIDEOS_BLUEY))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.series_videos(82_728)
|
||||
.await
|
||||
.expect("lookup succeeds")
|
||||
.expect("the teaser is chosen");
|
||||
|
||||
assert_eq!(trailer.key, "bluey_teaser");
|
||||
}
|
||||
|
||||
/// Only `YouTube` can be a trailer. A title whose videos are all Vimeo has none.
|
||||
#[tokio::test]
|
||||
async fn vimeo_only_video_lists_yield_no_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/1/videos"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"id": 1, "results": [
|
||||
{"key": "vimeo_one", "site": "Vimeo", "type": "Trailer", "name": "Trailer", "official": true}
|
||||
]}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(1)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(trailer, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_video_list_yields_no_trailer() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/3/movie/1/videos"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "results": []}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let trailer = client(&server)
|
||||
.movie_videos(1)
|
||||
.await
|
||||
.expect("lookup succeeds");
|
||||
|
||||
assert_eq!(trailer, None);
|
||||
}
|
||||
|
||||
+59
-1
@@ -63,6 +63,7 @@ import {
|
||||
type ApiSeries,
|
||||
type EpisodeFile,
|
||||
episodeTarget,
|
||||
fetchEpisode,
|
||||
fetchSeasons,
|
||||
fetchSeries,
|
||||
fetchSeriesFiles,
|
||||
@@ -305,7 +306,7 @@ function main() {
|
||||
// the surface underneath must be live before the detail view covers
|
||||
// it, so back and Esc land somewhere real (same as a movie deep link)
|
||||
library.open();
|
||||
seriesDetail.open(
|
||||
await seriesDetail.open(
|
||||
route.seriesId,
|
||||
must<HTMLElement>("#nav-library"),
|
||||
must<HTMLElement>("#library"),
|
||||
@@ -315,6 +316,61 @@ function main() {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "seasonReleases":
|
||||
case "episodeReleases": {
|
||||
// both deck routes live over the series detail view, exactly as a
|
||||
// click on a deck button does; resolve what that click would have
|
||||
let title: string;
|
||||
let sub: string;
|
||||
let seriesId: number;
|
||||
let target: TvTarget;
|
||||
if (route.kind === "seasonReleases") {
|
||||
seriesId = route.seriesId;
|
||||
target = seasonTarget(route.seriesId, route.seasonNumber);
|
||||
sub = `season ${PAD_TWO(route.seasonNumber)} · packs`;
|
||||
title = (await fetchSeries(route.seriesId))?.title ?? "";
|
||||
if (title === "") {
|
||||
navigate({ kind: "library" }, { replace: true });
|
||||
library.open();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const episode = await fetchEpisode(route.episodeId);
|
||||
if (!episode) {
|
||||
navigate({ kind: "library" }, { replace: true });
|
||||
library.open();
|
||||
return;
|
||||
}
|
||||
seriesId = episode.series_id;
|
||||
target = episodeTarget(episode.id);
|
||||
sub = `S${PAD_TWO(episode.season_number)}E${PAD_TWO(episode.number)} · ${episode.title}`;
|
||||
title = (await fetchSeries(seriesId))?.title ?? "";
|
||||
if (title === "") {
|
||||
navigate({ kind: "library" }, { replace: true });
|
||||
library.open();
|
||||
return;
|
||||
}
|
||||
}
|
||||
library.open();
|
||||
await seriesDetail.open(
|
||||
seriesId,
|
||||
must<HTMLElement>("#nav-library"),
|
||||
must<HTMLElement>("#library"),
|
||||
{ kind: "library" },
|
||||
);
|
||||
// no origin click to restore focus to on a deep link — the series
|
||||
// view's back control is the closest stand-in
|
||||
tvDeck.open({
|
||||
title,
|
||||
sub,
|
||||
seriesId,
|
||||
target,
|
||||
origin: must<HTMLButtonElement>("#series-back"),
|
||||
returnTo: must<HTMLElement>("#series"),
|
||||
parentRoute: { kind: "series", seriesId },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2327,6 +2383,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
navigate({ kind: "seasonReleases", seriesId: currentId, seasonNumber: season.number });
|
||||
tvDeck.open({
|
||||
title: currentTitle,
|
||||
sub: `season ${PAD_TWO(season.number)} · packs`,
|
||||
@@ -2470,6 +2527,7 @@ function seriesMain(board: HTMLElement, tvDeck: TvReleasesView, views: HideableV
|
||||
if (currentId === null) {
|
||||
return;
|
||||
}
|
||||
navigate({ kind: "episodeReleases", episodeId: episode.id });
|
||||
tvDeck.open({
|
||||
title: currentTitle,
|
||||
sub: `S${PAD_TWO(seasonNumber)}E${PAD_TWO(episode.number)} · ${episode.title}`,
|
||||
|
||||
+30
-1
@@ -9,7 +9,9 @@ export type Route =
|
||||
| { kind: "settings" }
|
||||
| { kind: "search"; query: string }
|
||||
| { kind: "releases"; movieId: number }
|
||||
| { kind: "series"; seriesId: number };
|
||||
| { kind: "series"; seriesId: number }
|
||||
| { kind: "seasonReleases"; seriesId: number; seasonNumber: number }
|
||||
| { kind: "episodeReleases"; episodeId: number };
|
||||
|
||||
export function parseRoute(url: URL): Route {
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
@@ -38,6 +40,29 @@ export function parseRoute(url: URL): Route {
|
||||
return { kind: "series", seriesId };
|
||||
}
|
||||
}
|
||||
if (
|
||||
segments.length === 5 &&
|
||||
segments[0] === "series" &&
|
||||
segments[2] === "seasons" &&
|
||||
segments[4] === "releases"
|
||||
) {
|
||||
const seriesId = Number(segments[1]);
|
||||
const seasonNumber = Number(segments[3]);
|
||||
if (
|
||||
Number.isInteger(seriesId) &&
|
||||
seriesId > 0 &&
|
||||
Number.isInteger(seasonNumber) &&
|
||||
seasonNumber >= 0
|
||||
) {
|
||||
return { kind: "seasonReleases", seriesId, seasonNumber };
|
||||
}
|
||||
}
|
||||
if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") {
|
||||
const episodeId = Number(segments[1]);
|
||||
if (Number.isInteger(episodeId) && episodeId > 0) {
|
||||
return { kind: "episodeReleases", episodeId };
|
||||
}
|
||||
}
|
||||
return { kind: "board" };
|
||||
}
|
||||
|
||||
@@ -57,6 +82,10 @@ export function routePath(route: Route): string {
|
||||
return `/movies/${route.movieId}/releases`;
|
||||
case "series":
|
||||
return `/series/${route.seriesId}`;
|
||||
case "seasonReleases":
|
||||
return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`;
|
||||
case "episodeReleases":
|
||||
return `/episodes/${route.episodeId}/releases`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export interface ApiSeries {
|
||||
|
||||
export interface ApiEpisode {
|
||||
id: number;
|
||||
series_id: number;
|
||||
season_id: number;
|
||||
season_number: number;
|
||||
number: number;
|
||||
title: string;
|
||||
air_date: string | null;
|
||||
@@ -54,6 +56,16 @@ export async function fetchSeries(seriesId: number): Promise<ApiSeries | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** One episode by row id — the `/episodes/{id}/releases` deep link's lookup. */
|
||||
export async function fetchEpisode(episodeId: number): Promise<ApiEpisode | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/episodes/${episodeId}`);
|
||||
return response.ok ? ((await response.json()) as ApiEpisode) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
|
||||
try {
|
||||
const response = await fetch(`/api/series/${seriesId}/seasons`);
|
||||
|
||||
Reference in New Issue
Block a user