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.
852 lines
26 KiB
Rust
852 lines
26 KiB
Rust
//! The slice of TMDB's schema this project actually reads.
|
|
//!
|
|
//! TMDB returns a great deal more than this. Everything kept here is either
|
|
//! shown to a person or fed to the policy engine; the rest is dropped at the
|
|
//! edge so nothing downstream has to know TMDB's field names.
|
|
|
|
use chrono::{Datelike, NaiveDate};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// TMDB's release-date type for a digital release. See §6.2 — this is the one
|
|
/// that gates targeted search.
|
|
const RELEASE_TYPE_DIGITAL: u8 = 4;
|
|
|
|
/// One row of a TMDB search.
|
|
///
|
|
/// `vote_average` is the one float, so this is `PartialEq` only.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct MovieSearchResult {
|
|
/// TMDB's own id, the key everything else hangs off.
|
|
pub tmdb_id: u32,
|
|
/// Title in the requested language.
|
|
pub title: String,
|
|
/// Title in the original language.
|
|
pub original_title: String,
|
|
/// ISO 639-1, e.g. `en` or `pt`. See [`Movie::original_language`] for why
|
|
/// this alone is not enough for Portuguese.
|
|
pub original_language: String,
|
|
/// Primary (theatrical) release date. Absent for announced-but-undated
|
|
/// titles, where TMDB sends an empty string.
|
|
pub release_date: Option<NaiveDate>,
|
|
/// TMDB's synopsis, empty string normalised away.
|
|
pub overview: Option<String>,
|
|
/// Path fragment, not a URL. TMDB's image base is a separate concern.
|
|
pub poster_path: Option<String>,
|
|
/// TMDB's rating, out of 10, when TMDB has votes for it; zero normalised
|
|
/// away like [`Movie::vote_average`].
|
|
pub vote_average: Option<f64>,
|
|
/// How many votes the rating rests on.
|
|
pub vote_count: u32,
|
|
}
|
|
|
|
impl MovieSearchResult {
|
|
/// Release year, when TMDB has a release date at all.
|
|
#[must_use]
|
|
pub fn year(&self) -> Option<i32> {
|
|
self.release_date.map(|date| date.year())
|
|
}
|
|
}
|
|
|
|
/// A movie as TMDB describes it, reduced to what this project uses.
|
|
///
|
|
/// `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,
|
|
/// `IMDb` id, when TMDB knows one. Torznab `t=movie` searches take it.
|
|
pub imdb_id: Option<String>,
|
|
/// Title in the requested language.
|
|
pub title: String,
|
|
/// Title in the original language.
|
|
pub original_title: String,
|
|
/// ISO 639-1, e.g. `en` or `pt`.
|
|
///
|
|
/// The dub rule in §5.2 compares a track's language against this. ISO
|
|
/// 639-1 has one code for both Portuguese variants, so `pt` on its own
|
|
/// cannot tell a Brazilian film from a Portuguese one — pair it with
|
|
/// [`Movie::origin_countries`] to resolve that.
|
|
pub original_language: String,
|
|
/// ISO 3166-1 alpha-2 country codes the title originates from. Needed to
|
|
/// read `pt` as pt-BR or pt-PT.
|
|
pub origin_countries: Vec<String>,
|
|
/// Primary (theatrical) release date.
|
|
pub release_date: Option<NaiveDate>,
|
|
/// Earliest digital release date TMDB records, across every country.
|
|
///
|
|
/// §6.2: a movie with no digital release date gets zero targeted searches.
|
|
/// Earliest-anywhere is the right reading of that — a release existing in
|
|
/// one region is a release that exists on the indexers.
|
|
pub digital_release: Option<NaiveDate>,
|
|
/// Runtime in minutes, when known.
|
|
pub runtime: Option<u32>,
|
|
/// TMDB's own status string: `Released`, `Post Production`, and so on.
|
|
pub status: String,
|
|
/// TMDB's synopsis, empty string normalised away.
|
|
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, when TMDB has votes for it. Stored with the
|
|
/// artwork (§9.6). TMDB sends `0` where "no rating yet" is meant, so a
|
|
/// zero rating is normalised away here rather than stored as one.
|
|
pub vote_average: Option<f64>,
|
|
/// How many votes the rating rests on.
|
|
pub vote_count: u32,
|
|
}
|
|
|
|
impl Movie {
|
|
/// Release year, when TMDB has a release date at all.
|
|
#[must_use]
|
|
pub fn year(&self) -> Option<i32> {
|
|
self.release_date.map(|date| date.year())
|
|
}
|
|
|
|
/// Whether a digital release exists as of `on`.
|
|
///
|
|
/// This is the §6.2 gate in one place: no digital date at all, or a date
|
|
/// still in the future, means the release does not exist yet and searching
|
|
/// for it is a wasted query.
|
|
#[must_use]
|
|
pub fn is_digitally_released(&self, on: NaiveDate) -> bool {
|
|
self.digital_release.is_some_and(|date| date <= on)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SeriesSearchResult {
|
|
pub tmdb_id: u32,
|
|
pub title: String,
|
|
pub original_language: String,
|
|
pub first_air_date: Option<NaiveDate>,
|
|
pub overview: Option<String>,
|
|
pub poster_path: Option<String>,
|
|
/// Zero normalised away, like [`Movie::vote_average`].
|
|
pub vote_average: Option<f64>,
|
|
/// How many votes the rating rests on.
|
|
pub vote_count: u32,
|
|
}
|
|
|
|
impl SeriesSearchResult {
|
|
#[must_use]
|
|
pub fn year(&self) -> Option<i32> {
|
|
self.first_air_date.map(|date| date.year())
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
pub title: String,
|
|
pub original_language: String,
|
|
pub first_air_date: Option<NaiveDate>,
|
|
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, when TMDB has votes for it. Stored with the
|
|
/// artwork (§9.6); zero is normalised away like [`Movie::vote_average`].
|
|
pub vote_average: Option<f64>,
|
|
/// How many votes the rating rests on.
|
|
pub vote_count: u32,
|
|
pub seasons: Vec<SeasonSummary>,
|
|
}
|
|
|
|
impl Series {
|
|
#[must_use]
|
|
pub fn year(&self) -> Option<i32> {
|
|
self.first_air_date.map(|date| date.year())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SeasonSummary {
|
|
pub number: u32,
|
|
pub episode_count: u32,
|
|
}
|
|
|
|
/// Placeholder for an episode TMDB has not named yet. #121's refresh replaces
|
|
/// it once TMDB fills the title in.
|
|
pub const UNTITLED_EPISODE: &str = "TBA";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Season {
|
|
pub number: u32,
|
|
pub episodes: Vec<Episode>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Episode {
|
|
pub number: u32,
|
|
pub title: String,
|
|
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>,
|
|
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
|
|
/// where "no votes yet" is meant, and a detail page must not show it as
|
|
/// a rating.
|
|
pub vote_average: Option<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>,
|
|
/// Zero normalised away, like [`Movie::vote_average`]: TMDB sends `0`
|
|
/// where "no votes yet" is meant, and a detail page must not show it as
|
|
/// a rating.
|
|
pub vote_average: Option<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.
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSearchPage {
|
|
#[serde(default)]
|
|
pub(crate) results: Vec<RawSearchResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSeriesSearchPage {
|
|
#[serde(default)]
|
|
pub(crate) results: Vec<RawSeriesSearchResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSeriesSearchResult {
|
|
id: u32,
|
|
#[serde(default)]
|
|
name: String,
|
|
#[serde(default)]
|
|
original_language: String,
|
|
#[serde(default)]
|
|
first_air_date: Option<String>,
|
|
#[serde(default)]
|
|
overview: Option<String>,
|
|
#[serde(default)]
|
|
poster_path: Option<String>,
|
|
#[serde(default)]
|
|
vote_average: f64,
|
|
#[serde(default)]
|
|
vote_count: u32,
|
|
}
|
|
|
|
impl From<RawSeriesSearchResult> for SeriesSearchResult {
|
|
fn from(raw: RawSeriesSearchResult) -> Self {
|
|
Self {
|
|
tmdb_id: raw.id,
|
|
title: raw.name,
|
|
original_language: raw.original_language,
|
|
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
|
|
overview: non_empty(raw.overview),
|
|
poster_path: non_empty(raw.poster_path),
|
|
vote_average: rating(raw.vote_average),
|
|
vote_count: raw.vote_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSeries {
|
|
id: u32,
|
|
#[serde(default)]
|
|
name: String,
|
|
#[serde(default)]
|
|
original_language: String,
|
|
#[serde(default)]
|
|
first_air_date: Option<String>,
|
|
#[serde(default)]
|
|
status: String,
|
|
#[serde(default)]
|
|
overview: Option<String>,
|
|
#[serde(default)]
|
|
poster_path: Option<String>,
|
|
#[serde(default)]
|
|
backdrop_path: Option<String>,
|
|
#[serde(default)]
|
|
vote_average: f64,
|
|
#[serde(default)]
|
|
vote_count: u32,
|
|
#[serde(default)]
|
|
seasons: Vec<RawSeasonSummary>,
|
|
#[serde(default)]
|
|
external_ids: Option<RawExternalIds>,
|
|
}
|
|
|
|
/// The slice of `/tv/{id}/external_ids` this project reads.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ExternalIds {
|
|
/// The Torznab `tvdbid` parameter (§6.1), when TMDB knows one.
|
|
pub tvdb_id: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawExternalIds {
|
|
pub(crate) tvdb_id: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawSeasonSummary {
|
|
season_number: u32,
|
|
#[serde(default)]
|
|
episode_count: u32,
|
|
}
|
|
|
|
impl From<RawSeries> for Series {
|
|
fn from(raw: RawSeries) -> Self {
|
|
Self {
|
|
tmdb_id: raw.id,
|
|
tvdb_id: raw.external_ids.and_then(|ids| ids.tvdb_id),
|
|
title: raw.name,
|
|
original_language: raw.original_language,
|
|
first_air_date: raw.first_air_date.as_deref().and_then(parse_date),
|
|
status: raw.status,
|
|
overview: non_empty(raw.overview),
|
|
poster_path: non_empty(raw.poster_path),
|
|
backdrop_path: non_empty(raw.backdrop_path),
|
|
vote_average: rating(raw.vote_average),
|
|
vote_count: raw.vote_count,
|
|
seasons: raw
|
|
.seasons
|
|
.into_iter()
|
|
.map(|season| SeasonSummary {
|
|
number: season.season_number,
|
|
episode_count: season.episode_count,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSeason {
|
|
season_number: u32,
|
|
#[serde(default)]
|
|
episodes: Vec<RawEpisode>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawEpisode {
|
|
episode_number: u32,
|
|
#[serde(default)]
|
|
name: String,
|
|
#[serde(default)]
|
|
air_date: Option<String>,
|
|
}
|
|
|
|
impl From<RawSeason> for Season {
|
|
fn from(raw: RawSeason) -> Self {
|
|
Self {
|
|
number: raw.season_number,
|
|
episodes: raw
|
|
.episodes
|
|
.into_iter()
|
|
.map(|episode| Episode {
|
|
number: episode.episode_number,
|
|
// TMDB leaves an unaired episode's name empty; a placeholder
|
|
// keeps "" out of search, filenames and the compat shim.
|
|
title: if episode.name.is_empty() {
|
|
UNTITLED_EPISODE.to_owned()
|
|
} else {
|
|
episode.name
|
|
},
|
|
air_date: episode.air_date.as_deref().and_then(parse_date),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What one `IMDb` id resolved to. An id names one title, so at most one of
|
|
/// the two lists is non-empty — but TMDB answers both kinds in the same
|
|
/// response, and both are surfaced rather than filtered here.
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct FindResults {
|
|
pub movies: Vec<MovieSearchResult>,
|
|
pub series: Vec<SeriesSearchResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawFindPage {
|
|
#[serde(default)]
|
|
pub(crate) movie_results: Vec<RawSearchResult>,
|
|
#[serde(default)]
|
|
pub(crate) tv_results: Vec<RawSeriesSearchResult>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawSearchResult {
|
|
id: u32,
|
|
#[serde(default)]
|
|
title: String,
|
|
#[serde(default)]
|
|
original_title: String,
|
|
#[serde(default)]
|
|
original_language: String,
|
|
#[serde(default)]
|
|
release_date: Option<String>,
|
|
#[serde(default)]
|
|
overview: Option<String>,
|
|
#[serde(default)]
|
|
poster_path: Option<String>,
|
|
#[serde(default)]
|
|
vote_average: f64,
|
|
#[serde(default)]
|
|
vote_count: u32,
|
|
}
|
|
|
|
impl From<RawSearchResult> for MovieSearchResult {
|
|
fn from(raw: RawSearchResult) -> Self {
|
|
Self {
|
|
tmdb_id: raw.id,
|
|
title: raw.title,
|
|
original_title: raw.original_title,
|
|
original_language: raw.original_language,
|
|
release_date: raw.release_date.as_deref().and_then(parse_date),
|
|
overview: non_empty(raw.overview),
|
|
poster_path: non_empty(raw.poster_path),
|
|
vote_average: rating(raw.vote_average),
|
|
vote_count: raw.vote_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub(crate) struct RawMovie {
|
|
id: u32,
|
|
#[serde(default)]
|
|
imdb_id: Option<String>,
|
|
#[serde(default)]
|
|
title: String,
|
|
#[serde(default)]
|
|
original_title: String,
|
|
#[serde(default)]
|
|
original_language: String,
|
|
#[serde(default)]
|
|
origin_country: Vec<String>,
|
|
#[serde(default)]
|
|
production_countries: Vec<RawProductionCountry>,
|
|
#[serde(default)]
|
|
release_date: Option<String>,
|
|
#[serde(default)]
|
|
runtime: Option<u32>,
|
|
#[serde(default)]
|
|
status: String,
|
|
#[serde(default)]
|
|
overview: Option<String>,
|
|
#[serde(default)]
|
|
poster_path: Option<String>,
|
|
#[serde(default)]
|
|
backdrop_path: Option<String>,
|
|
#[serde(default)]
|
|
vote_average: f64,
|
|
#[serde(default)]
|
|
vote_count: u32,
|
|
#[serde(default)]
|
|
release_dates: Option<RawReleaseDates>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawProductionCountry {
|
|
iso_3166_1: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawReleaseDates {
|
|
#[serde(default)]
|
|
results: Vec<RawCountryReleaseDates>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawCountryReleaseDates {
|
|
#[serde(default)]
|
|
release_dates: Vec<RawReleaseDate>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawReleaseDate {
|
|
#[serde(rename = "type")]
|
|
kind: u8,
|
|
#[serde(default)]
|
|
release_date: Option<String>,
|
|
}
|
|
|
|
impl From<RawMovie> for Movie {
|
|
fn from(raw: RawMovie) -> Self {
|
|
let digital_release = raw
|
|
.release_dates
|
|
.as_ref()
|
|
.and_then(|dates| earliest_digital(&dates.results));
|
|
|
|
// `origin_country` is the newer field and the more precise one;
|
|
// `production_countries` is the fallback for records that predate it.
|
|
let origin_countries = if raw.origin_country.is_empty() {
|
|
raw.production_countries
|
|
.into_iter()
|
|
.map(|country| country.iso_3166_1)
|
|
.collect()
|
|
} else {
|
|
raw.origin_country
|
|
};
|
|
|
|
Self {
|
|
tmdb_id: raw.id,
|
|
imdb_id: non_empty(raw.imdb_id),
|
|
title: raw.title,
|
|
original_title: raw.original_title,
|
|
original_language: raw.original_language,
|
|
origin_countries,
|
|
release_date: raw.release_date.as_deref().and_then(parse_date),
|
|
digital_release,
|
|
runtime: raw.runtime,
|
|
status: raw.status,
|
|
overview: non_empty(raw.overview),
|
|
poster_path: non_empty(raw.poster_path),
|
|
backdrop_path: non_empty(raw.backdrop_path),
|
|
vote_average: rating(raw.vote_average),
|
|
vote_count: raw.vote_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn earliest_digital(countries: &[RawCountryReleaseDates]) -> Option<NaiveDate> {
|
|
countries
|
|
.iter()
|
|
.flat_map(|country| country.release_dates.iter())
|
|
.filter(|entry| entry.kind == RELEASE_TYPE_DIGITAL)
|
|
.filter_map(|entry| entry.release_date.as_deref().and_then(parse_datetime))
|
|
.min()
|
|
}
|
|
|
|
/// TMDB sends `""` rather than `null` for a date it does not have.
|
|
fn parse_date(raw: &str) -> Option<NaiveDate> {
|
|
NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()
|
|
}
|
|
|
|
/// Release-date entries are timestamps: `2024-04-16T00:00:00.000Z`. The clock
|
|
/// part is meaningless — TMDB stamps a local date as UTC midnight — so only
|
|
/// the date survives.
|
|
fn parse_datetime(raw: &str) -> Option<NaiveDate> {
|
|
chrono::DateTime::parse_from_rfc3339(raw)
|
|
.ok()
|
|
.map(|stamp| stamp.date_naive())
|
|
.or_else(|| parse_date(raw))
|
|
}
|
|
|
|
/// TMDB uses `""` where `null` is meant, in most string fields.
|
|
fn non_empty(value: Option<String>) -> Option<String> {
|
|
value.filter(|text| !text.is_empty())
|
|
}
|
|
|
|
/// TMDB uses `0` where "no votes yet" is meant, so an unrated title never
|
|
/// carries a rating rather than carrying zero.
|
|
fn rating(value: f64) -> Option<f64> {
|
|
(value != 0.0).then_some(value)
|
|
}
|
|
|
|
#[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: rating(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: rating(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),
|
|
}
|
|
}
|
|
}
|