feat(meta): TMDB search and movie lookup (#49)
This commit was merged in pull request #49.
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 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())
|
||||
}
|
||||
Reference in New Issue
Block a user