use anyhow::{anyhow, Context, Result}; use reqwest::blocking::{Client, RequestBuilder}; use serde::Deserialize; use crate::metadata::{Candidate, MovieMetadata, Provider}; use crate::metadata::cache::Cache; use crate::metadata::SearchQuery; use crate::utils::{normalize_title, Semaphore}; #[derive(Debug, Deserialize)] struct TmdbSearchResponse { results: Option>, } #[derive(Debug, Deserialize)] struct TmdbSearchItem { id: u32, title: String, release_date: Option, } #[derive(Debug, Deserialize)] struct TmdbDetailResponse { id: u32, title: Option, release_date: Option, runtime: Option, imdb_id: Option, } pub fn search( client: &Client, base_url: &str, api_key: &str, query: &SearchQuery, cache: &Cache, net_sem: &Semaphore, ) -> Result> { let key = format!("{}:{}", normalize_title(&query.title), query.year.unwrap_or(0)); if let Some(cached) = cache.get("tmdb_search", &key)? { return parse_search(&cached); } let _permit = net_sem.acquire(); let url = format!("{}/search/movie", base_url.trim_end_matches('/')); let mut req = apply_auth(client.get(url), api_key) .query(&[("query", &query.title)]); if let Some(year) = query.year { req = req.query(&[("year", year.to_string())]); } let resp = req.send().context("TMDb search request failed")?; let status = resp.status(); if !status.is_success() { return Err(anyhow!("TMDb search failed with HTTP {status}")); } let text = resp.text().context("failed to read TMDb response")?; cache.set("tmdb_search", &key, &text)?; parse_search(&text) } fn parse_search(raw: &str) -> Result> { let parsed: TmdbSearchResponse = serde_json::from_str(raw) .with_context(|| "failed to parse TMDb search JSON")?; let mut candidates = Vec::new(); if let Some(items) = parsed.results { for item in items { let year = item.release_date.as_deref().and_then(parse_year); candidates.push(Candidate { provider: Provider::Tmdb, id: item.id.to_string(), title: item.title, year, runtime_minutes: None, }); } } Ok(candidates) } pub fn details( client: &Client, base_url: &str, api_key: &str, id: &str, cache: &Cache, net_sem: &Semaphore, ) -> Result { if let Some(cached) = cache.get("tmdb_details", id)? { return parse_details(&cached); } let _permit = net_sem.acquire(); let url = format!("{}/movie/{}", base_url.trim_end_matches('/'), id); let resp = apply_auth(client.get(url), api_key).send() .context("TMDb details request failed")?; let status = resp.status(); if !status.is_success() { return Err(anyhow!("TMDb details failed with HTTP {status}")); } let text = resp.text().context("failed to read TMDb details")?; cache.set("tmdb_details", id, &text)?; parse_details(&text) } fn parse_details(raw: &str) -> Result { let parsed: TmdbDetailResponse = serde_json::from_str(raw) .with_context(|| "failed to parse TMDb details JSON")?; let title = parsed.title.unwrap_or_else(|| "Unknown Title".to_string()); let year = parsed .release_date .as_deref() .and_then(parse_year) .unwrap_or(0); let tmdb_id = Some(parsed.id); Ok(MovieMetadata { title, year, tmdb_id, imdb_id: parsed.imdb_id, provider: Provider::Tmdb, runtime_minutes: parsed.runtime, }) } fn apply_auth(req: RequestBuilder, api_key: &str) -> RequestBuilder { if looks_like_bearer(api_key) { req.bearer_auth(api_key) } else { req.query(&[("api_key", api_key)]) } } fn looks_like_bearer(value: &str) -> bool { value.contains('.') && value.len() > 30 } fn parse_year(raw: &str) -> Option { raw.get(0..4).and_then(|s| s.parse::().ok()) }