//! 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. #[derive(Debug, Clone, PartialEq, Eq, 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, /// TMDB's synopsis, empty string normalised away. pub overview: Option, /// Path fragment, not a URL. TMDB's image base is a separate concern. pub poster_path: Option, } impl MovieSearchResult { /// Release year, when TMDB has a release date at all. #[must_use] pub fn year(&self) -> Option { self.release_date.map(|date| date.year()) } } /// A movie as TMDB describes it, reduced to what this project uses. #[derive(Debug, Clone, PartialEq, Eq, 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, /// 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, /// Primary (theatrical) release date. pub release_date: Option, /// 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, /// Runtime in minutes, when known. pub runtime: Option, /// TMDB's own status string: `Released`, `Post Production`, and so on. pub status: String, /// TMDB's synopsis, empty string normalised away. pub overview: Option, /// Path fragment, not a URL. pub poster_path: Option, } impl Movie { /// Release year, when TMDB has a release date at all. #[must_use] pub fn year(&self) -> Option { 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, Eq, Serialize, Deserialize)] pub struct SeriesSearchResult { pub tmdb_id: u32, pub title: String, pub original_language: String, pub first_air_date: Option, pub overview: Option, pub poster_path: Option, } impl SeriesSearchResult { #[must_use] pub fn year(&self) -> Option { self.first_air_date.map(|date| date.year()) } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Series { pub tmdb_id: u32, pub tvdb_id: Option, pub title: String, pub original_language: String, pub first_air_date: Option, pub status: String, pub overview: Option, pub poster_path: Option, pub seasons: Vec, } impl Series { #[must_use] pub fn year(&self) -> Option { 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, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Episode { pub number: u32, pub title: String, pub air_date: Option, } // --- 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, } #[derive(Debug, Deserialize)] pub(crate) struct RawSeriesSearchPage { #[serde(default)] pub(crate) results: Vec, } #[derive(Debug, Deserialize)] pub(crate) struct RawSeriesSearchResult { id: u32, #[serde(default)] name: String, #[serde(default)] original_language: String, #[serde(default)] first_air_date: Option, #[serde(default)] overview: Option, #[serde(default)] poster_path: Option, } impl From 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), } } } #[derive(Debug, Deserialize)] pub(crate) struct RawSeries { id: u32, #[serde(default)] name: String, #[serde(default)] original_language: String, #[serde(default)] first_air_date: Option, #[serde(default)] status: String, #[serde(default)] overview: Option, #[serde(default)] poster_path: Option, #[serde(default)] seasons: Vec, #[serde(default)] external_ids: Option, } /// 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, } #[derive(Debug, Deserialize)] pub(crate) struct RawExternalIds { pub(crate) tvdb_id: Option, } #[derive(Debug, Deserialize)] struct RawSeasonSummary { season_number: u32, #[serde(default)] episode_count: u32, } impl From 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), 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, } #[derive(Debug, Deserialize)] struct RawEpisode { episode_number: u32, #[serde(default)] name: String, #[serde(default)] air_date: Option, } impl From 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, Eq)] pub struct FindResults { pub movies: Vec, pub series: Vec, } #[derive(Debug, Deserialize)] pub(crate) struct RawFindPage { #[serde(default)] pub(crate) movie_results: Vec, #[serde(default)] pub(crate) tv_results: Vec, } #[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, #[serde(default)] overview: Option, #[serde(default)] poster_path: Option, } impl From 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), } } } #[derive(Debug, Deserialize)] pub(crate) struct RawMovie { id: u32, #[serde(default)] imdb_id: Option, #[serde(default)] title: String, #[serde(default)] original_title: String, #[serde(default)] original_language: String, #[serde(default)] origin_country: Vec, #[serde(default)] production_countries: Vec, #[serde(default)] release_date: Option, #[serde(default)] runtime: Option, #[serde(default)] status: String, #[serde(default)] overview: Option, #[serde(default)] poster_path: Option, #[serde(default)] release_dates: Option, } #[derive(Debug, Deserialize)] struct RawProductionCountry { iso_3166_1: String, } #[derive(Debug, Deserialize)] struct RawReleaseDates { #[serde(default)] results: Vec, } #[derive(Debug, Deserialize)] struct RawCountryReleaseDates { #[serde(default)] release_dates: Vec, } #[derive(Debug, Deserialize)] struct RawReleaseDate { #[serde(rename = "type")] kind: u8, #[serde(default)] release_date: Option, } impl From 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), } } } fn earliest_digital(countries: &[RawCountryReleaseDates]) -> Option { 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::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 { 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) -> Option { value.filter(|text| !text.is_empty()) }