//! TMDB client for the §6 stage 3 cast cross-check. //! //! §5a's Threat 2 defence rests entirely on the attacker not controlling TMDB: //! to make a prank manifest pass, they would need those performers to be //! credited cast on that title in TMDB, which means vandalising a separate, //! moderated system. //! //! Responses are cached for 24h (§6) so a burst of episode uploads for one //! series costs a single upstream call, and so the server stays within TMDB's //! own rate limits. use std::time::Duration; use serde::Deserialize; /// A credited cast member, reduced to what the check needs. #[derive(Debug, Clone, Deserialize)] pub struct CastMember { pub id: u64, #[serde(default)] pub name: String, #[serde(default)] pub adult: bool, } #[derive(Debug, Clone, Default, Deserialize)] pub struct Credits { #[serde(default)] pub cast: Vec, /// Present on episode credits. #[serde(default)] pub guest_stars: Vec, } impl Credits { /// Cast plus guest stars — the union §6 specifies for episodes. pub fn all(&self) -> impl Iterator { self.cast.iter().chain(self.guest_stars.iter()) } } #[derive(Debug, Clone, Default, Deserialize)] pub struct TitleDetails { #[serde(default)] pub adult: bool, #[serde(default)] pub title: Option, #[serde(default)] pub name: Option, } /// A failure that should be retried rather than treated as a verdict. /// /// §6: "TMDB unreachable / rate-limited → retry with backoff; stays unlisted, /// not rejected." Distinguishing this from "TMDB has no credits" is essential — /// conflating them would reject honest manifests during an outage. #[derive(Debug, thiserror::Error)] pub enum TmdbError { #[error("tmdb transport error: {0}")] Transport(String), #[error("tmdb rate limited")] RateLimited, #[error("tmdb server error: {0}")] ServerError(u16), /// The id genuinely does not exist upstream. #[error("tmdb resource not found")] NotFound, #[error("tmdb response was not understood: {0}")] Malformed(String), #[error("no tmdb api key configured")] NotConfigured, } impl TmdbError { /// True when the job should be rescheduled rather than resolved. pub fn is_retryable(&self) -> bool { matches!( self, TmdbError::Transport(_) | TmdbError::RateLimited | TmdbError::ServerError(_) | TmdbError::NotConfigured ) } } #[derive(Clone)] pub struct TmdbClient { http: reqwest::Client, base_url: String, api_key: Option, } impl TmdbClient { pub fn new(base_url: String, api_key: Option) -> Self { let http = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .user_agent(concat!("jray-server/", env!("CARGO_PKG_VERSION"))) .build() .expect("building reqwest client"); Self { http, base_url, api_key } } pub fn is_configured(&self) -> bool { self.api_key.is_some() } async fn get(&self, path: &str) -> Result { let key = self.api_key.as_deref().ok_or(TmdbError::NotConfigured)?; let url = format!("{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/')); let resp = self .http .get(&url) .query(&[("api_key", key)]) .send() .await .map_err(|e| TmdbError::Transport(e.to_string()))?; let status = resp.status(); if status == reqwest::StatusCode::NOT_FOUND { return Err(TmdbError::NotFound); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { return Err(TmdbError::RateLimited); } if status.is_server_error() { return Err(TmdbError::ServerError(status.as_u16())); } if !status.is_success() { return Err(TmdbError::Malformed(format!("unexpected status {status}"))); } let body = resp.text().await.map_err(|e| TmdbError::Transport(e.to_string()))?; serde_json::from_str(&body).map_err(|e| TmdbError::Malformed(e.to_string())) } pub async fn movie_credits(&self, tmdb_id: &str) -> Result { self.get(&format!("movie/{tmdb_id}/credits")).await } pub async fn movie_details(&self, tmdb_id: &str) -> Result { self.get(&format!("movie/{tmdb_id}")).await } pub async fn series_credits(&self, series_tmdb_id: &str) -> Result { // Aggregate credits carry recurring cast TMDB lists only at series level. self.get(&format!("tv/{series_tmdb_id}/aggregate_credits")).await } pub async fn episode_credits( &self, series_tmdb_id: &str, season: i64, episode: i64, ) -> Result { self.get(&format!("tv/{series_tmdb_id}/season/{season}/episode/{episode}/credits")).await } pub async fn series_details(&self, series_tmdb_id: &str) -> Result { self.get(&format!("tv/{series_tmdb_id}")).await } } /// Fetches a person's details, used by the §5a category guard. #[derive(Debug, Clone, Default, Deserialize)] pub struct PersonDetails { #[serde(default)] pub adult: bool, #[serde(default)] pub name: String, } impl TmdbClient { pub async fn person(&self, tmdb_person_id: u64) -> Result { self.get(&format!("person/{tmdb_person_id}")).await } } #[cfg(test)] mod tests { use super::*; #[test] fn credits_union_covers_cast_and_guest_stars() { // §6: for episodes the check runs against the union of per-episode // credits (cast + guest stars) and series aggregate credits. let c: Credits = serde_json::from_str( r#"{"cast":[{"id":1,"name":"A"}],"guest_stars":[{"id":2,"name":"B"}]}"#, ) .unwrap(); let ids: Vec = c.all().map(|m| m.id).collect(); assert_eq!(ids, vec![1, 2]); } #[test] fn credits_tolerate_missing_and_extra_fields() { // TMDB adds fields freely; our own strictness applies to *uploads*, not // to a trusted upstream we merely read. let c: Credits = serde_json::from_str(r#"{"cast":[{"id":1,"unexpected":true}],"id":99}"#).unwrap(); assert_eq!(c.cast.len(), 1); assert_eq!(c.cast[0].name, ""); assert!(c.guest_stars.is_empty()); } #[test] fn transport_and_rate_limit_are_retryable_but_not_found_is_not() { // The distinction that keeps an outage from rejecting honest uploads. assert!(TmdbError::Transport("x".into()).is_retryable()); assert!(TmdbError::RateLimited.is_retryable()); assert!(TmdbError::ServerError(503).is_retryable()); assert!(TmdbError::NotConfigured.is_retryable()); assert!(!TmdbError::NotFound.is_retryable()); assert!(!TmdbError::Malformed("x".into()).is_retryable()); } #[tokio::test] async fn unconfigured_client_reports_retryable_failure() { let c = TmdbClient::new("http://127.0.0.1:1".into(), None); assert!(!c.is_configured()); let err = c.movie_credits("1").await.unwrap_err(); assert!(err.is_retryable(), "missing key must hold uploads pending, not reject them"); } }