Files
meta-feeder-tribler/feeder-plugin/tribler-feeder/src/tmdb.rs
T
Worph a7cb27984b
Publish image / publish (push) Failing after 13s
align with metadata standard
2026-09-13 15:56:35 +02:00

928 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TMDB HTTP client + DTOs + season/episode bounds check.
//!
//! Split out of the monolithic `torznab.rs` (pure file move; no behaviour change).
use meta_feeder_sdk::common::urlencode;
use std::time::Duration;
use tracing::debug;
// -- TMDB metadata enrichment ------------------------------------------------
//
// TMDB v4 bearer-token client. Two endpoints used:
//
// GET /3/search/{movie,tv}?query=<urlencoded>&include_adult=false[&year=<YYYY>]
// GET <image_base>/<poster_path>
//
// The token-bearing `Authorization` header gates both. Free-tier rate
// limit is ~40 req / 10 s — we don't hit it from a dev box but it's
// worth knowing. All TMDB failures degrade silently: enrichment is a
// best-effort augmentation, never a hard dep.
/// Lightweight TMDB v4 client. Cheap to clone (the underlying
/// `reqwest::Client` is `Arc`-backed).
#[derive(Clone)]
pub struct TmdbClient {
http: reqwest::Client,
bearer_token: String,
api_base: String,
image_base: String,
}
impl TmdbClient {
pub fn new(bearer_token: String) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(TMDB_POSTER_TIMEOUT_SECS))
.user_agent(USER_AGENT)
.build()
.expect("rustls reqwest client build infallible");
Self {
http,
bearer_token,
api_base: TMDB_API_BASE.to_string(),
image_base: TMDB_IMAGE_BASE.to_string(),
}
}
#[cfg(test)]
pub(crate) fn with_bases(token: String, api_base: String, image_base: String) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(TMDB_POSTER_TIMEOUT_SECS))
.user_agent(USER_AGENT)
.build()
.expect("rustls reqwest client build infallible");
Self {
http,
bearer_token: token,
api_base,
image_base,
}
}
/// Search TMDB for a movie/TV title. Returns [`TmdbCall::Hit`] with the
/// top result, [`TmdbCall::Miss`] when there are no matches (or any
/// transient/timeout error — best-effort degrade), or
/// [`TmdbCall::RateLimited`] carrying the Retry-After window on a 429 so
/// the caller can pause the shared budget globally. Wraps the whole call
/// (including JSON decode) in [`TMDB_SEARCH_TIMEOUT_SECS`].
pub(crate) async fn search(
&self,
kind: TmdbKind,
title: &str,
year: Option<u16>,
) -> TmdbCall<TmdbHit> {
let endpoint = match kind {
TmdbKind::Movie => "search/movie",
TmdbKind::Tv => "search/tv",
};
let mut url = format!(
"{}/{}?query={}&include_adult=false",
self.api_base.trim_end_matches('/'),
endpoint,
urlencode(title),
);
if let Some(y) = year {
// Movie uses `year`, TV uses `first_air_date_year`; both
// are documented but movies accept `year` for backward-
// compat which is what we want.
url.push_str(&format!("&year={y}"));
}
let fut = self
.http
.get(&url)
.bearer_auth(&self.bearer_token)
.header("accept", "application/json")
.send();
let resp =
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
Ok(Ok(r)) => r,
_ => return TmdbCall::Miss,
};
if resp.status().as_u16() == 429 {
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
}
if !resp.status().is_success() {
debug!(
target: "meta-share::gateway",
upstream = "prowlarr",
tmdb_url = %url,
status = %resp.status(),
"tmdb search non-2xx; degrading"
);
return TmdbCall::Miss;
}
let body: TmdbSearchResponse =
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json())
.await
{
Ok(Ok(b)) => b,
_ => return TmdbCall::Miss,
};
match body.results.into_iter().next() {
Some(top) => TmdbCall::Hit(top.into_hit(kind)),
None => TmdbCall::Miss,
}
}
/// Build the public TMDB poster CDN URL for `poster_path` (no fetch). In the
/// feeder model the feeder no longer fetches+stores the poster bytes; it
/// emits this URL as a `poster_url` field and the **gateway core** seeds it
/// into a content-addressed `poster` cid (same path as giphy/wikicommons
/// previews). Inserts the required [`TMDB_POSTER_SIZE`] segment.
pub(crate) fn poster_cdn_url(&self, poster_path: &str) -> String {
format!(
"{}/{}/{}",
self.image_base.trim_end_matches('/'),
TMDB_POSTER_SIZE,
poster_path.trim_start_matches('/'),
)
}
/// Fetch a poster image's raw bytes. `poster_path` is the
/// TMDB-supplied path (typically `"/abcdef.jpg"` with leading slash).
///
/// `image_base` is the bare CDN root (`https://image.tmdb.org/t/p`); TMDB
/// requires a **size segment** (`/w500`, `/original`, …) between the root
/// and the file or it 404s. The const comment documents this contract and
/// this caller honors it by inserting [`TMDB_POSTER_SIZE`].
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) async fn fetch_poster(&self, poster_path: &str) -> Option<bytes::Bytes> {
let url = format!(
"{}/{}/{}",
self.image_base.trim_end_matches('/'),
TMDB_POSTER_SIZE,
poster_path.trim_start_matches('/'),
);
let resp = self.http.get(&url).send().await.ok()?;
if !resp.status().is_success() {
debug!(
target: "meta-share::gateway",
upstream = "prowlarr",
poster_url = %url,
status = %resp.status(),
"tmdb poster fetch non-2xx; skipping"
);
return None;
}
resp.bytes().await.ok()
}
/// Fetch a TV show's authoritative structure (`number_of_seasons`
/// plus the per-season `episode_count`s) via `GET /3/tv/{id}`. Used
/// only to bounds-check a title-parsed season/episode — see
/// [`season_episode_bounds`]. Wrapped in the same search timeout;
/// any failure returns `None` so validation degrades to "accept".
pub(crate) async fn tv_details(&self, tmdbid: u64) -> TmdbCall<TmdbTvDetails> {
let url = format!("{}/tv/{}", self.api_base.trim_end_matches('/'), tmdbid,);
let fut = self
.http
.get(&url)
.bearer_auth(&self.bearer_token)
.header("accept", "application/json")
.send();
let resp =
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
Ok(Ok(r)) => r,
_ => return TmdbCall::Miss,
};
if resp.status().as_u16() == 429 {
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
}
if !resp.status().is_success() {
debug!(
target: "meta-share::gateway",
upstream = "prowlarr",
tmdb_url = %url,
status = %resp.status(),
"tmdb tv-details non-2xx; skipping season/episode validation"
);
return TmdbCall::Miss;
}
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json()).await
{
Ok(Ok(d)) => TmdbCall::Hit(d),
_ => TmdbCall::Miss,
}
}
/// Fetch cross-database ids (`tvdb_id`, `imdb_id`) for a known tmdbid via
/// `GET /3/{tv,movie}/{id}/external_ids`. The anchored torznab path needs
/// `tvdb_id` to query indexers by `tvsearch&tvdbid=` (TMDB's id is not a
/// standard `tvsearch` param). Same timeout/429 contract as [`tv_details`].
pub(crate) async fn external_ids(
&self,
kind: TmdbKind,
tmdbid: u64,
) -> TmdbCall<TmdbExternalIds> {
let seg = match kind {
TmdbKind::Movie => "movie",
TmdbKind::Tv => "tv",
};
let url = format!(
"{}/{}/{}/external_ids",
self.api_base.trim_end_matches('/'),
seg,
tmdbid,
);
self.get_json(&url, "external_ids").await
}
/// Fetch a movie's canonical details via `GET /3/movie/{id}` (the anchored
/// enrichment source for a known movie tmdbid — resolves title/overview/
/// poster/year/imdb_id without a fuzzy title search). Same contract as
/// [`tv_details`].
pub(crate) async fn movie_details(&self, tmdbid: u64) -> TmdbCall<TmdbMovieDetails> {
let url = format!("{}/movie/{}", self.api_base.trim_end_matches('/'), tmdbid);
self.get_json(&url, "movie-details").await
}
/// Principal search via `GET /3/search/multi` — maps a bare keyword to a
/// canonical tmdbid + media type so the gateway can then query indexers
/// structurally (the "TMDB as the front door" identification step
/// Sonarr/Radarr use). Returns the mixed movie/tv/person result list; the
/// caller picks the top-N confident anchors via [`principal_top_n`].
pub(crate) async fn search_multi(&self, query: &str) -> TmdbCall<Vec<TmdbMultiItem>> {
let url = format!(
"{}/search/multi?query={}&include_adult=false",
self.api_base.trim_end_matches('/'),
urlencode(query),
);
match self
.get_json::<TmdbMultiResponse>(&url, "search/multi")
.await
{
TmdbCall::Hit(r) => TmdbCall::Hit(r.results),
TmdbCall::Miss => TmdbCall::Miss,
TmdbCall::RateLimited(d) => TmdbCall::RateLimited(d),
}
}
/// Fetch a TMDB **discovery list** (trending / popular / top-rated / a
/// `/discover` query) and map each result to a [`TmdbHit`]. Powers the
/// keyword-less discovery branch (see [`super::discovery`]): the caller
/// composes `path_and_query` (e.g. `"movie/popular"`,
/// `"trending/tv/week"`, or `"discover/tv?with_keywords=210024&sort_by=…"`)
/// and passes the `kind` so movie-vs-TV title/date fields decode correctly.
/// All these endpoints share the `{results: [TmdbSearchItem]}` shape, so one
/// method covers them. Returns the page's hits, or `Miss`/`RateLimited` under
/// the usual best-effort contract.
pub(crate) async fn discovery_list(
&self,
kind: TmdbKind,
path_and_query: &str,
) -> TmdbCall<Vec<TmdbHit>> {
let url = format!(
"{}/{}",
self.api_base.trim_end_matches('/'),
path_and_query.trim_start_matches('/'),
);
match self.get_json::<TmdbSearchResponse>(&url, "discovery").await {
TmdbCall::Hit(r) => {
TmdbCall::Hit(r.results.into_iter().map(|i| i.into_hit(kind)).collect())
}
TmdbCall::Miss => TmdbCall::Miss,
TmdbCall::RateLimited(d) => TmdbCall::RateLimited(d),
}
}
/// Shared `GET <url>` → JSON helper with the standard bearer auth, search
/// timeout, 429→`RateLimited`, non-2xx/transient→`Miss` contract. Factored
/// out of [`tv_details`]/[`external_ids`]/[`movie_details`] (identical
/// modulo the decoded type and the log label).
async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str, what: &str) -> TmdbCall<T> {
let fut = self
.http
.get(url)
.bearer_auth(&self.bearer_token)
.header("accept", "application/json")
.send();
let resp =
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), fut).await {
Ok(Ok(r)) => r,
// Transport error / timeout. Logged at debug so the otherwise
// silent best-effort degrade is diagnosable.
Ok(Err(e)) => {
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, error=%e, "tmdb call send error; degrading");
return TmdbCall::Miss;
}
Err(_) => {
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, "tmdb call send timeout; degrading");
return TmdbCall::Miss;
}
};
if resp.status().as_u16() == 429 {
return TmdbCall::RateLimited(parse_tmdb_retry_after(&resp));
}
if !resp.status().is_success() {
debug!(
target: "meta-share::gateway",
upstream = "prowlarr",
tmdb_url = %url,
status = %resp.status(),
what,
"tmdb call non-2xx; degrading"
);
return TmdbCall::Miss;
}
match tokio::time::timeout(Duration::from_secs(TMDB_SEARCH_TIMEOUT_SECS), resp.json()).await {
Ok(Ok(d)) => TmdbCall::Hit(d),
// A decode failure here folds into `Miss` (best-effort), but log it
// at debug: a single malformed element fails the whole `Vec` decode,
// which would otherwise silently disable e.g. multi-anchor.
Ok(Err(e)) => {
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, error=%e, "tmdb call decode error; degrading");
TmdbCall::Miss
}
Err(_) => {
debug!(target: "meta-share::gateway", upstream="prowlarr", tmdb_url=%url, what, "tmdb call decode timeout; degrading");
TmdbCall::Miss
}
}
}
}
/// Outcome of a TMDB API call. Distinguishes a 429 rate-limit (which feeds
/// `Retry-After` into the shared [`TmdbBudget`] so all enrichment pauses
/// globally) from a plain miss or transient error (best-effort degrade).
pub(crate) enum TmdbCall<T> {
Hit(T),
Miss,
RateLimited(Duration),
}
/// Parse a TMDB `Retry-After` header into a pause duration, defaulting to
/// [`TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS`] when absent/unparseable. Mirrors
/// the indexer-path logic in [`map_status`].
pub(crate) fn parse_tmdb_retry_after(resp: &reqwest::Response) -> Duration {
let secs = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(TMDB_RATE_LIMIT_DEFAULT_RETRY_SECS);
Duration::from_secs(secs)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TmdbKind {
Movie,
Tv,
}
#[derive(serde::Deserialize)]
pub(crate) struct TmdbSearchResponse {
#[serde(default)]
pub(crate) results: Vec<TmdbSearchItem>,
}
#[derive(serde::Deserialize)]
pub(crate) struct TmdbSearchItem {
pub(crate) id: u64,
#[serde(default)]
pub(crate) title: Option<String>, // movie
#[serde(default)]
pub(crate) name: Option<String>, // tv
#[serde(default)]
pub(crate) original_title: Option<String>, // movie
#[serde(default)]
pub(crate) original_name: Option<String>, // tv
#[serde(default)]
pub(crate) overview: Option<String>,
#[serde(default)]
pub(crate) release_date: Option<String>, // movie, YYYY-MM-DD
#[serde(default)]
pub(crate) first_air_date: Option<String>, // tv, YYYY-MM-DD
#[serde(default)]
pub(crate) poster_path: Option<String>,
#[serde(default)]
pub(crate) genre_ids: Vec<u32>,
/// ISO 639-1 (2-letter) language the title was originally produced in.
/// Mapped to `lang3` and used to file `original_title` as a
/// `titles/{lang3}/{name}` member (METADATA_KEYS.md §3).
#[serde(default)]
pub(crate) original_language: Option<String>,
}
impl TmdbSearchItem {
pub(crate) fn into_hit(self, kind: TmdbKind) -> TmdbHit {
let title = match kind {
TmdbKind::Movie => self.title,
TmdbKind::Tv => self.name,
}
.unwrap_or_else(|| "(untitled)".to_string());
let original_title = match kind {
TmdbKind::Movie => self.original_title,
TmdbKind::Tv => self.original_name,
};
let date = match kind {
TmdbKind::Movie => self.release_date,
TmdbKind::Tv => self.first_air_date,
};
let year = date
.as_deref()
.filter(|d| d.len() >= 4)
.and_then(|d| d[..4].parse::<u16>().ok());
TmdbHit {
tmdbid: self.id,
title,
original_title,
original_language: self.original_language,
overview: self.overview,
year,
poster_path: self.poster_path,
genre_ids: self.genre_ids,
}
}
}
/// Map an ISO 639-1 (2-letter) code to its ISO 639-3 (`lang3`) equivalent,
/// matching the store's convention (`eng`, `jpn`, `fra`; the 639-2/T variant
/// where B/T differ, e.g. `deu` not `ger`). Covers the languages TMDB
/// commonly returns; unknown codes return `None` so the caller files the name under `und`
/// rather than persisting a non-`lang3` key.
pub(crate) fn iso639_1_to_3(code: &str) -> Option<&'static str> {
Some(match code.to_ascii_lowercase().as_str() {
"en" => "eng",
"ja" => "jpn",
"fr" => "fra",
"de" => "deu",
"es" => "spa",
"it" => "ita",
"ru" => "rus",
"ko" => "kor",
"zh" => "zho",
"pt" => "por",
"nl" => "nld",
"sv" => "swe",
"no" => "nor",
"da" => "dan",
"fi" => "fin",
"pl" => "pol",
"tr" => "tur",
"ar" => "ara",
"hi" => "hin",
"th" => "tha",
"vi" => "vie",
"id" => "ind",
"cs" => "ces",
"el" => "ell",
"he" => "heb",
"hu" => "hun",
"ro" => "ron",
"uk" => "ukr",
"fa" => "fas",
"ms" => "msa",
"tl" => "tgl",
"ca" => "cat",
"nb" => "nob",
_ => return None,
})
}
// `Clone` is required so the single-flight `Shared` future (whose `Output`
// must be `Clone`) can hand the same hit to every coalesced caller; serde so
// hits persist in the redb TMDB-search cache.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbHit {
pub(crate) tmdbid: u64,
pub(crate) title: String,
#[serde(default)]
pub(crate) original_title: Option<String>,
#[serde(default)]
pub(crate) original_language: Option<String>,
#[serde(default)]
pub(crate) overview: Option<String>,
#[serde(default)]
pub(crate) year: Option<u16>,
#[serde(default)]
pub(crate) poster_path: Option<String>,
#[serde(default)]
#[allow(dead_code)] // surfaced as CSV of ids; name resolution would need a second call
pub(crate) genre_ids: Vec<u32>,
}
impl TmdbHit {
/// TMDB `original_language` (ISO 639-1) mapped to the store's `lang3`
/// (ISO 639-3), or `None` when the language is outside the common set or
/// absent. Used to file `original_title` under `titles/{lang3}/{name}` (§3).
pub(crate) fn original_lang3(&self) -> Option<&'static str> {
iso639_1_to_3(self.original_language.as_deref()?.trim())
}
}
/// The language TMDB's `name`/`title` is written in. Every request goes out
/// without a `language` param, so TMDB answers in its default, en-US.
pub(crate) const METADATA_LANG3: &str = "eng";
/// The `titles/{lang3}/{name}` key-set member for `name` (METADATA_KEYS.md), or
/// `None` when it normalises to empty: trimmed, whitespace runs collapsed,
/// case/diacritics/`%` verbatim, and `/` (the key-set separator) written as
/// U+2215 `∕`.
///
/// CROSS-BINARY CONTRACT: the indexer feeder's `tmdb.rs::title_member_key` and
/// the card feeder's `tmdb_client.rs::title_member_key` write the same shape;
/// two peers must produce the same key for one name.
pub(crate) fn title_member_key(lang3: &str, name: &str) -> Option<String> {
let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
if name.is_empty() {
return None;
}
Some(format!("titles/{lang3}/{}", name.replace('/', "\u{2215}")))
}
/// The `titles/{lang3}/{name}` members this feeder files from a TMDB hit: its
/// title and original title (a search hit carries no AKAs). The original goes
/// under its `original_language` (`und` when unmapped); the title under
/// [`METADATA_LANG3`] **unless it equals the original** — TMDB falls back to
/// the original name when it has no en-US translation, so `3%` is
/// `titles/por/3%` only, not also an English name. Same rule as the indexer
/// feeder's `tmdb_title_member_keys`.
pub(crate) fn tmdb_title_member_keys(hit: &TmdbHit) -> Vec<String> {
let norm = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
let mut keys = Vec::new();
let original = hit.original_title.as_deref().map(norm).filter(|o| !o.is_empty());
if let Some(o) = &original {
keys.extend(title_member_key(hit.original_lang3().unwrap_or("und"), o));
}
let title = norm(&hit.title);
if title != "(untitled)" && original.as_deref() != Some(title.as_str()) {
keys.extend(title_member_key(METADATA_LANG3, &title));
}
keys
}
/// Authoritative TV structure from `GET /3/tv/{id}`, used to bounds-check
/// a title-parsed season/episode. Only the two structural fields are
/// decoded; everything else in the (large) details payload is ignored.
// `Clone` + `Serialize` added so details persist in the redb TMDB-tvdetails
// cache and can be cheaply handed around.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbTvDetails {
#[serde(default)]
pub(crate) number_of_seasons: u32,
/// One entry per season TMDB knows about, including season 0
/// ("Specials"). `episode_count` is the released-episode total.
#[serde(default)]
pub(crate) seasons: Vec<TmdbSeasonSummary>,
// -- Display fields ------------------------------------------------------
// Decoded from the same `GET /3/tv/{id}` payload so an anchored TV record
// (known tmdbid) enriches directly from its canonical entry instead of a
// fuzzy title search. Old cache entries (written before these fields
// existed) deserialize them to empty/None; [`TmdbTvDetails::has_display`]
// detects that so the cached-fetch path can self-heal with one refetch.
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) original_name: Option<String>,
#[serde(default)]
pub(crate) original_language: Option<String>,
#[serde(default)]
pub(crate) overview: Option<String>,
#[serde(default)]
pub(crate) first_air_date: Option<String>,
#[serde(default)]
pub(crate) poster_path: Option<String>,
}
impl TmdbTvDetails {
/// True when the cached payload carries the display fields (i.e. was
/// written by the current decoder). A `false` here on a cache hit means a
/// pre-display entry — the caller refetches once to upgrade it.
pub(crate) fn has_display(&self) -> bool {
!self.name.trim().is_empty()
}
/// Build a [`TmdbHit`] (resolved by id) from the canonical TV entry, for
/// the anchored enrichment path. `None` when display fields are absent.
pub(crate) fn as_hit(&self, tmdbid: u64) -> Option<TmdbHit> {
if !self.has_display() {
return None;
}
let year = self
.first_air_date
.as_deref()
.filter(|d| d.len() >= 4)
.and_then(|d| d[..4].parse::<u16>().ok());
Some(TmdbHit {
tmdbid,
title: self.name.clone(),
original_title: self.original_name.clone(),
original_language: self.original_language.clone(),
overview: self.overview.clone(),
year,
poster_path: self.poster_path.clone(),
genre_ids: Vec::new(),
})
}
}
/// TMDB cross-database ids from `GET /3/{tv,movie}/{id}/external_ids`. Only the
/// two the anchored torznab path needs are decoded.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbExternalIds {
#[serde(default)]
pub(crate) tvdb_id: Option<i64>,
#[serde(default)]
pub(crate) imdb_id: Option<String>,
}
/// Authoritative movie structure from `GET /3/movie/{id}`. Decodes only the
/// display fields the anchored path needs; `imdb_id` (movie details carry it
/// inline, no separate `external_ids` call) feeds the `t=movie&imdbid=` query
/// fallback.
#[derive(Debug, Clone, serde::Deserialize)]
pub(crate) struct TmdbMovieDetails {
pub(crate) id: u64,
#[serde(default)]
pub(crate) title: Option<String>,
#[serde(default)]
pub(crate) original_title: Option<String>,
#[serde(default)]
pub(crate) original_language: Option<String>,
#[serde(default)]
pub(crate) overview: Option<String>,
#[serde(default)]
pub(crate) release_date: Option<String>,
#[serde(default)]
pub(crate) poster_path: Option<String>,
}
impl TmdbMovieDetails {
pub(crate) fn into_hit(self) -> TmdbHit {
let year = self
.release_date
.as_deref()
.filter(|d| d.len() >= 4)
.and_then(|d| d[..4].parse::<u16>().ok());
TmdbHit {
tmdbid: self.id,
title: self.title.unwrap_or_else(|| "(untitled)".to_string()),
original_title: self.original_title,
original_language: self.original_language,
overview: self.overview,
year,
poster_path: self.poster_path,
genre_ids: Vec::new(),
}
}
}
#[derive(serde::Deserialize)]
pub(crate) struct TmdbMultiResponse {
#[serde(default)]
pub(crate) results: Vec<TmdbMultiItem>,
}
/// One `search/multi` result. `media_type` discriminates movie / tv / person;
/// we anchor only on movie/tv.
#[derive(Debug, Clone, serde::Deserialize)]
pub(crate) struct TmdbMultiItem {
pub(crate) id: u64,
#[serde(default)]
pub(crate) media_type: Option<String>,
#[serde(default)]
pub(crate) title: Option<String>, // movie
#[serde(default)]
pub(crate) name: Option<String>, // tv
#[serde(default)]
pub(crate) popularity: f64,
}
impl TmdbMultiItem {
pub(crate) fn kind(&self) -> Option<TmdbKind> {
match self.media_type.as_deref() {
Some("movie") => Some(TmdbKind::Movie),
Some("tv") => Some(TmdbKind::Tv),
_ => None,
}
}
pub(crate) fn display_title(&self) -> &str {
self.title.as_deref().or(self.name.as_deref()).unwrap_or("")
}
}
/// Lowercase alphanumeric word tokens of `s` (separators dropped). Shared by
/// the principal-search confidence check.
pub(crate) fn norm_tokens(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(|t| t.to_lowercase())
.collect()
}
/// Pick up to `n` **confident** anchors from `search/multi` hits for `query`,
/// most-popular first (or `[]` → the caller falls back to today's generic
/// `t=search&q=`). For each movie/tv hit, accept it only if its title shares at
/// least half of the query's word tokens (the per-candidate relevance gate),
/// then rank the survivors by popularity and take the top `n`. This binds a
/// vague keyword like "black" to the *several* real shows TMDB returns (Black
/// Mirror, Black Butler, Black Lagoon, …) instead of hijacking the whole query
/// onto the single most-popular title, while still dropping unrelated noise
/// (e.g. "zzz qqq www" → nothing). `n == 1` recovers the old single-anchor
/// "principal_confident" behaviour (modulo the gate being applied before the
/// popularity pick rather than after).
pub(crate) fn principal_top_n<'a>(
hits: &'a [TmdbMultiItem],
query: &str,
n: usize,
) -> Vec<&'a TmdbMultiItem> {
let q_tokens = norm_tokens(query);
if q_tokens.is_empty() || n == 0 {
return Vec::new();
}
let mut cands: Vec<&TmdbMultiItem> = hits
.iter()
.filter(|h| h.kind().is_some())
.filter(|h| {
let t_tokens = norm_tokens(h.display_title());
let overlap = q_tokens.iter().filter(|t| t_tokens.contains(t)).count();
(overlap as f64) >= (q_tokens.len() as f64) * 0.5
})
.collect();
cands.sort_by(|a, b| {
b.popularity
.partial_cmp(&a.popularity)
.unwrap_or(std::cmp::Ordering::Equal)
});
cands.truncate(n);
cands
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct TmdbSeasonSummary {
#[serde(default)]
pub(crate) season_number: i64,
#[serde(default)]
pub(crate) episode_count: u32,
}
/// Verdict of [`season_episode_bounds`] — how a title-parsed `(season,
/// episode)` lines up against TMDB's actual structure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SeasonEpisodeBounds {
/// Consistent with TMDB, or unverifiable — keep the record as-is.
Ok,
/// The parsed season sits past TMDB's known seasons and isn't otherwise
/// listed in `seasons[]`. The canonical case is anime where fansubbers
/// number a later cour `S2` of a show TMDB models as a **single** season
/// (Frieren, Jujutsu Kaisen, …). The TMDB match itself is sound — only the
/// season number is a naming-convention artifact — so the caller keeps the
/// record but strips the misleading `season` (the frontend buckets
/// season-less episodes into a flat "Episodes" list).
SeasonOverflow,
/// A *positive* contradiction the season-overflow allowance can't explain:
/// a negative season, or an episode beyond a **matched** season's
/// `episode_count`. The title parse is wrong — the caller drops the record.
Contradiction,
}
/// Pure bounds check: is a title-parsed `(season, episode)` consistent
/// with what TMDB says the show actually has? Deliberately lenient —
/// it only returns a verdict other than [`SeasonEpisodeBounds::Ok`] on a
/// *positive* divergence, so anything ambiguous or unverifiable is accepted:
///
/// - No parsed season → [`Ok`]. Episode-only titles (absolute-numbered
/// anime, trailing `- 117`) are legitimately unbounded; the season-
/// based bound doesn't apply.
/// - TMDB gave us no structure (`number_of_seasons == 0` and no
/// `seasons[]`) → [`Ok`]; we can't contradict what we don't know.
/// - Season exceeds `number_of_seasons` AND isn't otherwise listed in
/// `seasons[]` → [`SeasonOverflow`] (the `S2`-of-a-1-TMDB-season anime
/// case; keep but strip `season`).
/// - Negative season → [`Contradiction`].
/// - Episode exceeds the matched season's `episode_count` (when that
/// count is known) → [`Contradiction`]. This only fires for explicit
/// `SxxEyy` titles, the exact case where the per-season bound holds.
///
/// [`Ok`]: SeasonEpisodeBounds::Ok
/// [`SeasonOverflow`]: SeasonEpisodeBounds::SeasonOverflow
/// [`Contradiction`]: SeasonEpisodeBounds::Contradiction
pub(crate) fn season_episode_bounds(
details: &TmdbTvDetails,
season: Option<i64>,
episode: Option<i64>,
) -> SeasonEpisodeBounds {
use SeasonEpisodeBounds::*;
let season = match season {
Some(s) => s,
None => return Ok,
};
if details.number_of_seasons == 0 && details.seasons.is_empty() {
return Ok;
}
if season < 0 {
return Contradiction;
}
let matched = details.seasons.iter().find(|s| s.season_number == season);
if season > details.number_of_seasons as i64 && matched.is_none() {
return SeasonOverflow;
}
if let (Some(ep), Some(sm)) = (episode, matched) {
if sm.episode_count > 0 && ep > sm.episode_count as i64 {
return Contradiction;
}
}
Ok
}
/// True when `episode` is a valid episode of `season` per TMDB's per-season
/// `episode_count` (the season must be listed and the count known). Used to
/// decide whether a bare title number is an in-season episode before falling
/// back to absolute-number interpretation.
pub(crate) fn episode_in_season(details: &TmdbTvDetails, season: i64, episode: i64) -> bool {
details
.seasons
.iter()
.find(|s| s.season_number == season)
.map(|s| s.episode_count > 0 && episode >= 1 && episode <= s.episode_count as i64)
.unwrap_or(false)
}
use crate::consts::*;
#[cfg(test)]
mod season_bounds_tests {
use super::*;
fn details(num_seasons: u32, seasons: &[(i64, u32)]) -> TmdbTvDetails {
TmdbTvDetails {
number_of_seasons: num_seasons,
seasons: seasons
.iter()
.map(|&(season_number, episode_count)| TmdbSeasonSummary {
season_number,
episode_count,
})
.collect(),
..Default::default()
}
}
#[test]
fn bounds_ok_within_season() {
let d = details(2, &[(1, 26), (2, 24)]);
assert_eq!(season_episode_bounds(&d, Some(1), Some(5)), SeasonEpisodeBounds::Ok);
assert_eq!(season_episode_bounds(&d, Some(2), Some(24)), SeasonEpisodeBounds::Ok);
}
#[test]
fn bounds_no_season_or_unknown_structure_is_ok() {
let d = details(2, &[(1, 26), (2, 24)]);
assert_eq!(season_episode_bounds(&d, None, Some(999)), SeasonEpisodeBounds::Ok);
let unknown = details(0, &[]);
assert_eq!(season_episode_bounds(&unknown, Some(5), Some(40)), SeasonEpisodeBounds::Ok);
}
#[test]
fn bounds_season_past_total_is_overflow() {
// Fansub "S2" of a show TMDB models as a single season → strip-and-keep.
let d = details(1, &[(1, 28)]);
assert_eq!(season_episode_bounds(&d, Some(2), Some(3)), SeasonEpisodeBounds::SeasonOverflow);
}
#[test]
fn bounds_episode_past_matched_season_or_negative_is_contradiction() {
// "S1E27" on a 4-season show → contradiction. The enrich path drops the
// record (we no longer remap absolute numbers onto seasons; a genuine
// absolute release carries no season token and never reaches here).
let d = details(4, &[(1, 26), (2, 13), (3, 13), (4, 12)]);
assert_eq!(season_episode_bounds(&d, Some(1), Some(27)), SeasonEpisodeBounds::Contradiction);
assert_eq!(season_episode_bounds(&d, Some(-1), None), SeasonEpisodeBounds::Contradiction);
}
}
#[cfg(test)]
mod title_member_tests {
use super::*;
fn hit(title: &str, orig: Option<&str>, lang: Option<&str>) -> TmdbHit {
TmdbHit {
tmdbid: 1,
title: title.to_string(),
original_title: orig.map(str::to_string),
original_language: lang.map(str::to_string),
overview: None,
year: None,
poster_path: None,
genre_ids: Vec::new(),
}
}
#[test]
fn title_and_original_are_filed_as_language_nested_members() {
assert_eq!(
tmdb_title_member_keys(&hit("Naruto", Some("ナルト"), Some("ja"))),
vec!["titles/jpn/ナルト", "titles/eng/Naruto"]
);
// Title == original (no en-US translation) is filed once.
assert_eq!(tmdb_title_member_keys(&hit("3%", Some("3%"), Some("pt"))), vec!["titles/por/3%"]);
// Unmapped language → und; `/` → U+2215; TMDB's placeholder is not a name.
assert_eq!(
tmdb_title_member_keys(&hit("(untitled)", Some("Fate/Zero"), Some("xx"))),
vec!["titles/und/Fate\u{2215}Zero"]
);
assert_eq!(title_member_key("eng", " 3 Percent "), Some("titles/eng/3 Percent".to_string()));
assert_eq!(title_member_key("eng", " "), None);
}
}