Files
arr/crates/arr-meta/src/model.rs
T
2026-08-23 20:55:16 +01:00

471 lines
14 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.
#[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<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>,
}
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.
#[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<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>,
}
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, Eq, 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>,
}
impl SeriesSearchResult {
#[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 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>,
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>,
}
// --- 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>,
}
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),
}
}
}
#[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)]
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),
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, Eq)]
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>,
}
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),
}
}
}
#[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)]
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),
}
}
}
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())
}