//! The `tmdb` upstream — a **card** feeder plugin. //! //! Answers the discovery half of gateway search: free text in, `fileType=card` //! records out. It never calls an indexer and never serves bytes, which is the //! whole point — a card query costs at most one cached TMDB `search/multi`, so //! the expensive indexer budget is spent later, entirely on the single card the //! user clicks (design doc §1.2). //! //! When MyAnimeList lands it becomes a sibling `FeederPlugin` in this same //! binary, declaring the same `served_file_types = ["card"]`. use std::path::Path; use std::sync::Arc; use async_trait::async_trait; use meta_feeder_sdk::cache::MidhashCache; use meta_feeder_sdk::config::ConfigSchema; use meta_feeder_sdk::plugin::{ ConfigError, FeederPlugin, GatewayQuery, HashKind, HashOutcome, }; use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, Hash, PluginHealth}; use tracing::warn; use crate::card::{split_record_id, Card, CardSource}; use crate::consts::{DEFAULT_CARD_DISCOVERY_N, DEFAULT_CARD_TOP_N}; use crate::resolve::Resolver; use crate::tmdb_budget::{TmdbBudget, DEFAULT_TMDB_BURST, DEFAULT_TMDB_REFILL_PER_SEC}; use crate::tmdb_client::TmdbClient; /// Config the operator supplies via the feeder's own web form (invariant 12 — /// per-plugin config lives on the feeder, never on the gateway). #[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] pub struct TmdbConfig { #[serde(default)] pub tmdb_token: String, #[serde(default)] pub tmdb_rate_per_sec: Option, #[serde(default)] pub tmdb_burst: Option, #[serde(default)] pub card_top_n: Option, #[serde(default)] pub card_discovery_n: Option, } impl TmdbConfig { /// Env seed, used on first boot before any `config.json` exists. pub fn from_env() -> Self { Self { tmdb_token: std::env::var("TMDB_TOKEN").unwrap_or_default(), tmdb_rate_per_sec: std::env::var("TMDB_RATE_PER_SEC") .ok() .and_then(|v| v.parse().ok()), tmdb_burst: std::env::var("TMDB_BURST").ok().and_then(|v| v.parse().ok()), card_top_n: std::env::var("CARD_TOP_N").ok().and_then(|v| v.parse().ok()), card_discovery_n: std::env::var("CARD_DISCOVERY_N") .ok() .and_then(|v| v.parse().ok()), } } } pub struct TmdbCardPlugin { config: TmdbConfig, /// `None` until `configure` runs, and stays `None` when no TMDB token was /// supplied — the soft-skip path. The feeder still serves `/health` so /// `depends_on` is satisfied; queries just return nothing. resolver: Option, top_n: usize, /// Cards per keyword-less catalog row (see [`DEFAULT_CARD_DISCOVERY_N`]). discovery_n: usize, /// Test hook: `(api_base, image_base)` overriding TMDB's real endpoints, so /// the contract test can point the client at a wiremock server. api_bases: Option<(String, String)>, } impl Default for TmdbCardPlugin { fn default() -> Self { Self::new() } } impl TmdbCardPlugin { pub fn new() -> Self { Self { config: TmdbConfig::from_env(), resolver: None, top_n: DEFAULT_CARD_TOP_N, discovery_n: DEFAULT_CARD_DISCOVERY_N, api_bases: None, } } /// Test constructor: a fixed token plus TMDB endpoint overrides, so the /// contract test can drive the real router against a wiremock TMDB. pub fn with_api_base(token: &str, api_base: String, image_base: String) -> Self { let mut p = Self::new(); p.config.tmdb_token = token.to_string(); p.api_bases = Some((api_base, image_base)); p } /// File wins over env, exactly like every other feeder: `config.json` in the /// per-plugin cache dir is read at `configure` time, falling back to the env /// seed. No hot reload — bounce the feeder. fn load_config(&mut self, cache_dir: &Path) { let path = cache_dir.join("config.json"); if let Ok(bytes) = std::fs::read(&path) { if let Ok(file_cfg) = serde_json::from_slice::(&bytes) { if !file_cfg.tmdb_token.trim().is_empty() { self.config.tmdb_token = file_cfg.tmdb_token; } if file_cfg.tmdb_rate_per_sec.is_some() { self.config.tmdb_rate_per_sec = file_cfg.tmdb_rate_per_sec; } if file_cfg.tmdb_burst.is_some() { self.config.tmdb_burst = file_cfg.tmdb_burst; } if file_cfg.card_top_n.is_some() { self.config.card_top_n = file_cfg.card_top_n; } if file_cfg.card_discovery_n.is_some() { self.config.card_discovery_n = file_cfg.card_discovery_n; } } } } fn resolver(&self) -> Option<&Resolver> { self.resolver.as_ref() } } #[async_trait] impl FeederPlugin for TmdbCardPlugin { fn upstream_id(&self) -> &'static str { "tmdb" } /// **The routing declaration.** `card` on the fileType axis is what makes /// meta-search fan a `fileType:card` query out to this gateway — no /// meta-search change was needed to add the tier (design doc §6). The /// content kinds are the *work* kinds a card can describe. fn served_file_types(&self) -> &'static [&'static str] { &["card"] } fn served_content_kinds(&self) -> &'static [&'static str] { &["movie", "series"] } fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError> { self.load_config(cache_dir); let cache: MidhashCache = meta_feeder_sdk::common::open_midhash_cache(cache_dir, "tmdb")?; self.top_n = self.config.card_top_n.filter(|n| *n > 0).unwrap_or(DEFAULT_CARD_TOP_N); self.discovery_n = self .config .card_discovery_n .filter(|n| *n > 0) .unwrap_or(DEFAULT_CARD_DISCOVERY_N); let token = self.config.tmdb_token.trim().to_string(); if token.is_empty() { // Soft-skip, matching every other opt-in upstream: stay healthy so // the gateway's `depends_on` is satisfied, but answer nothing. warn!( target: "meta-share::gateway", upstream = "tmdb", "no TMDB token configured; card queries will return nothing \ (set it on the feeder's config page or via TMDB_TOKEN)" ); return Ok(()); } // `TmdbBudget::new` already returns an `Arc`. let budget = TmdbBudget::new( self.config .tmdb_rate_per_sec .filter(|v| *v > 0.0) .unwrap_or(DEFAULT_TMDB_REFILL_PER_SEC), self.config .tmdb_burst .filter(|v| *v > 0.0) .unwrap_or(DEFAULT_TMDB_BURST), ); let client = match &self.api_bases { Some((api, image)) => TmdbClient::with_bases(token, api.clone(), image.clone()), None => TmdbClient::new(token), }; self.resolver = Some(Resolver::new(Arc::new(client), cache, budget)); Ok(()) } fn health(&self) -> PluginHealth { PluginHealth::Ok } async fn handle_query( &self, query: &GatewayQuery, max_results: usize, ) -> Result, GatewayError> { // Layer A early-return: this plugin only ever serves cards. if !meta_feeder_sdk::query_eval::query_accepts_plugin( query, self.served_file_types(), self.served_content_kinds(), ) { // One misconfiguration lands here often enough to deserve a name. // A browse row written against the *indexer* feeder's dialect says // `contentKind:episode`, which is not in `served_content_kinds` — so // the row is rejected here, before TMDB is touched, and the operator // sees an empty wall with no error anywhere. A card describes a WORK, // and a work is a `series`, never an `episode`. if crate::discovery::is_discovery_query(query) { warn!( target: "meta-share::gateway", upstream = "tmdb", content_kind = ?query.filters.get("contentKind"), "discovery row rejected by the served-kinds gate: a card is a \ WORK — use contentKind:series for TV rows (not episode/tv/tvshow)" ); } return Ok(Vec::new()); } let Some(resolver) = self.resolver() else { return Ok(Vec::new()); // soft-skipped (no token) }; // Three shapes, in precedence order: // 1. an explicit `tmdbid:` filter — the direct "give me this work" // lookup a deep link (or the detail page) uses; // 2. a keyword-less catalog query (`popular:true contentKind:series`) // — a client's home row, answered from TMDB's catalog endpoints; // 3. free text — the principal search. let cards: Vec = if let Some(id) = query .filters .get("tmdbid") .and_then(|v| v.first()) .and_then(|s| s.trim().parse::().ok()) { let kind = kind_hint(query, resolver, id).await; resolver.card_by_id(id, kind).await.into_iter().collect() } else if crate::discovery::is_discovery_query(query) { let n = self.discovery_n.min(max_results.max(1)); crate::discovery::cards_for_discovery(resolver, query, n).await } else { let free_text = query.free_text.trim(); if free_text.is_empty() { return Ok(Vec::new()); } let n = self.top_n.min(max_results.max(1)); resolver.cards_for_text(free_text, n).await }; let client = &resolver.client; Ok(cards .iter() .filter_map(|c| c.to_record(client, &query.filters)) .take(max_results) .collect()) } /// Resolve a card `record_id` back into its locator CID. /// /// This is **not** a stub for a byte-less plugin — it is the card path. The /// outcome carries `bytes: None`, so the gateway core's three-branch /// auto-store routes it to `store_metadata_only`: the record lands in /// meta-core, nothing is written to WebDAV, and nothing is seeded to /// bitswap (seeding is gated on `Sha2_256`). The record IS the payload. /// /// The CID needs no network call — it is a pure function of the record id, /// which is itself `":"`. We re-resolve the card anyway /// so the stored record is complete; a resolution failure still yields the /// CID, because the identity is derivable regardless. async fn compute_outcomes(&self, record_id: &str) -> Result, GatewayError> { let (source, source_id) = split_record_id(record_id).ok_or_else(|| { GatewayError::Permanent(format!("malformed card record_id '{record_id}'")) })?; if source != CardSource::Tmdb.as_str() { return Err(GatewayError::NotFound); } let hash = meta_feeder_sdk::hash::compute_card_cid(source, source_id).ok_or_else(|| { GatewayError::Permanent(format!("card id too long to encode: '{record_id}'")) })?; let record = match (self.resolver(), parse_source_id(source_id)) { (Some(resolver), Some((kind, tmdbid))) => resolver .card_by_id(tmdbid, kind) .await .and_then(|c| c.to_record(&resolver.client, &Default::default())), _ => None, }; Ok(vec![HashOutcome { hash: Hash(hash), hash_kind: HashKind::CardLocator, bytes: None, record, file_extension: None, }]) } fn config_schema(&self) -> ConfigSchema { use meta_feeder_sdk::config::{ConfigField as F, ConfigSchema}; ConfigSchema { fields: vec![ F::secret("tmdb_token", "TMDB v4 token").with_help( "Read-access token from themoviedb.org. Without it this feeder \ soft-skips and card searches return nothing.", ), F::number("tmdb_rate_per_sec", "TMDB rate (req/s)").with_help( "Sustained TMDB request rate. Blank keeps the built-in default \ (20/s). Takes effect on the next feeder restart.", ), F::number("tmdb_burst", "TMDB burst (tokens)").with_help( "Short-burst TMDB token ceiling. Blank keeps the built-in \ default (20). Takes effect on the next feeder restart.", ), F::number("card_top_n", "Cards per search").with_help( "How many works a free-text search returns, most popular first. \ Blank keeps the built-in default (10). Unlike the old anchor \ top-N this costs no indexer requests — only TMDB.", ), F::number("card_discovery_n", "Cards per discovery row").with_help( "How many cards a keyword-less browse row (popular / trending / \ top-rated) returns. Blank keeps the built-in default (20 — \ exactly one TMDB catalog page, so a row costs ONE request). \ Separate from 'Cards per search': a row is a browse surface, \ not an answer to a question.", ), ], } } fn config_values(&self) -> serde_json::Value { serde_json::json!({ "tmdb_token": self.config.tmdb_token, "tmdb_rate_per_sec": self.config.tmdb_rate_per_sec, "tmdb_burst": self.config.tmdb_burst, "card_top_n": self.config.card_top_n, "card_discovery_n": self.config.card_discovery_n, }) } } /// Decide whether an explicit `tmdbid:` refers to a show or a film: an explicit /// `contentKind` filter wins, else probe TV details (a movie id has none). async fn kind_hint(query: &GatewayQuery, resolver: &Resolver, id: u64) -> crate::tmdb_client::TmdbKind { use crate::tmdb_client::TmdbKind; match query .filters .get("contentKind") .and_then(|v| v.first()) .map(|s| s.as_str()) { Some("movie") => return TmdbKind::Movie, Some("series") | Some("episode") | Some("tvshow") => return TmdbKind::Tv, _ => {} } match resolver.tv_details(id).await { Some(d) if d.number_of_seasons > 0 => TmdbKind::Tv, _ => TmdbKind::Movie, } } /// `"tv:95479"` / `"movie:27205"` → `(kind, tmdbid)`. fn parse_source_id(source_id: &str) -> Option<(crate::tmdb_client::TmdbKind, u64)> { use crate::tmdb_client::TmdbKind; let (kind, id) = source_id.split_once(':')?; let kind = match kind { "tv" => TmdbKind::Tv, "movie" => TmdbKind::Movie, _ => return None, }; Some((kind, id.trim().parse().ok()?)) } #[cfg(test)] mod tests { use super::*; use crate::tmdb_client::TmdbKind; #[test] fn source_id_parses_both_kinds() { assert!(matches!( parse_source_id("tv:95479"), Some((TmdbKind::Tv, 95479)) )); assert!(matches!( parse_source_id("movie:27205"), Some((TmdbKind::Movie, 27205)) )); assert!(parse_source_id("person:123").is_none()); assert!(parse_source_id("tv:notanumber").is_none()); assert!(parse_source_id("95479").is_none()); } /// The routing declaration is load-bearing: `card` here is the only reason /// meta-search fans a `fileType:card` query out to this gateway. #[test] fn declares_the_card_file_type() { let p = TmdbCardPlugin::new(); assert_eq!(p.served_file_types(), &["card"]); assert_eq!(p.served_content_kinds(), &["movie", "series"]); assert_eq!(p.upstream_id(), "tmdb"); } /// A card CID is derivable with no network and no resolver, so /// `compute_outcomes` must still produce one when the feeder is /// soft-skipped — only the record is missing. #[tokio::test] async fn compute_outcomes_yields_a_byteless_locator_without_a_token() { let p = TmdbCardPlugin::new(); let outcomes = p.compute_outcomes("tmdb:tv:95479").await.expect("outcomes"); assert_eq!(outcomes.len(), 1); let o = &outcomes[0]; assert_eq!(o.hash_kind, HashKind::CardLocator); assert!(o.bytes.is_none(), "a card has no bytes, ever"); assert!(o.file_extension.is_none()); assert_eq!( o.hash.0, meta_feeder_sdk::hash::compute_card_cid("tmdb", "tv:95479").unwrap() ); } #[tokio::test] async fn compute_outcomes_rejects_a_foreign_or_malformed_id() { let p = TmdbCardPlugin::new(); assert!(p.compute_outcomes("mal:52991").await.is_err()); assert!(p.compute_outcomes("nosource").await.is_err()); } /// Soft-skip: no token means no cards, not a hard failure — the feeder /// stays healthy so the gateway's `depends_on` is satisfied. #[tokio::test] async fn queries_return_nothing_without_a_token() { let p = TmdbCardPlugin::new(); let q = GatewayQuery::from_free_text("frieren"); assert!(p.handle_query(&q, 10).await.unwrap().is_empty()); assert!(matches!(p.health(), PluginHealth::Ok)); } /// Layer-A routing: a query for something this plugin cannot serve returns /// immediately without touching TMDB. #[tokio::test] async fn rejects_a_query_for_a_type_it_does_not_serve() { let p = TmdbCardPlugin::new(); let mut q = GatewayQuery::from_free_text("frieren"); q.filters .insert("fileType".to_string(), vec!["video".to_string()]); assert!(p.handle_query(&q, 10).await.unwrap().is_empty()); } }