From 9c11ac2bccd780228f0fbf07bbbe03c1e345f749 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 May 2026 02:30:53 +0200 Subject: [PATCH] feat: add TMDB metadata provider, prefer over OMDB when TMDB_API_KEY is set --- crates/adapters/metadata/src/lib.rs | 7 ++ crates/adapters/metadata/src/tmdb.rs | 179 +++++++++++++++++++++++++++ crates/presentation/src/main.rs | 10 +- 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 crates/adapters/metadata/src/tmdb.rs diff --git a/crates/adapters/metadata/src/lib.rs b/crates/adapters/metadata/src/lib.rs index 92d5d5c..0a2a612 100644 --- a/crates/adapters/metadata/src/lib.rs +++ b/crates/adapters/metadata/src/lib.rs @@ -7,6 +7,7 @@ use domain::{ }; mod omdb; +mod tmdb; pub(crate) struct ProviderMovie { pub imdb_id: ExternalMetadataId, @@ -31,6 +32,12 @@ impl MetadataClientImpl { provider: Box::new(omdb::OmdbProvider::new(api_key)), } } + + pub fn new_tmdb(api_key: String) -> Self { + Self { + provider: Box::new(tmdb::TmdbProvider::new(api_key)), + } + } } #[async_trait] diff --git a/crates/adapters/metadata/src/tmdb.rs b/crates/adapters/metadata/src/tmdb.rs new file mode 100644 index 0000000..7de9b09 --- /dev/null +++ b/crates/adapters/metadata/src/tmdb.rs @@ -0,0 +1,179 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + ports::MetadataSearchCriteria, + value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear}, +}; +use serde::Deserialize; + +use crate::{MetadataProvider, ProviderMovie}; + +pub(crate) struct TmdbProvider { + client: reqwest::Client, + api_key: String, +} + +impl TmdbProvider { + pub(crate) fn new(api_key: String) -> Self { + Self { + client: reqwest::Client::new(), + api_key, + } + } + + fn base(&self, path: &str) -> String { + format!("https://api.themoviedb.org/3{}", path) + } + + fn poster_url(&self, path: &str) -> Option { + if path.is_empty() || path == "null" { + return None; + } + PosterUrl::new(format!("https://image.tmdb.org/t/p/w500{}", path)).ok() + } + + async fn get Deserialize<'de>>( + &self, + url: &str, + extra: &[(&str, &str)], + ) -> Result { + let mut req = self + .client + .get(url) + .query(&[("api_key", self.api_key.as_str())]); + for (k, v) in extra { + req = req.query(&[(k, v)]); + } + req.send() + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .error_for_status() + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + .json::() + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + + async fn fetch_details(&self, tmdb_id: u64) -> Result { + #[derive(Deserialize)] + struct CrewMember { + job: String, + name: String, + } + #[derive(Deserialize)] + struct Credits { + crew: Vec, + } + #[derive(Deserialize)] + struct Details { + imdb_id: Option, + title: String, + release_date: String, // "YYYY-MM-DD" + poster_path: Option, + credits: Credits, + } + + let url = self.base(&format!("/movie/{}", tmdb_id)); + let d: Details = self + .get(&url, &[("append_to_response", "credits")]) + .await?; + + let year: u16 = d + .release_date + .split('-') + .next() + .and_then(|y| y.parse().ok()) + .ok_or_else(|| { + DomainError::InfrastructureError(format!( + "Unparseable release_date: {}", + d.release_date + )) + })?; + + // Prefer IMDB ID; fall back to "tmdb:{id}" so the record is still usable. + let raw_id = d + .imdb_id + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("tmdb:{}", tmdb_id)); + + let imdb_id = ExternalMetadataId::new(raw_id) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let title = + MovieTitle::new(d.title).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let release_year = + ReleaseYear::new(year).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let director = d + .credits + .crew + .into_iter() + .find(|c| c.job == "Director") + .map(|c| c.name); + + let poster_url = d + .poster_path + .as_deref() + .and_then(|p| self.poster_url(p)); + + Ok(ProviderMovie { + imdb_id, + title, + release_year, + director, + poster_url, + }) + } +} + +#[async_trait] +impl MetadataProvider for TmdbProvider { + async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result { + let tmdb_id: u64 = match criteria { + MetadataSearchCriteria::ImdbId(id) => { + #[derive(Deserialize)] + struct FindResult { + id: u64, + } + #[derive(Deserialize)] + struct FindResponse { + movie_results: Vec, + } + let url = self.base(&format!("/find/{}", id.value())); + let resp: FindResponse = + self.get(&url, &[("external_source", "imdb_id")]).await?; + resp.movie_results + .into_iter() + .next() + .ok_or_else(|| DomainError::NotFound(format!("TMDB: no movie for {}", id.value())))? + .id + } + MetadataSearchCriteria::Title { title, year } => { + #[derive(Deserialize)] + struct SearchResult { + id: u64, + } + #[derive(Deserialize)] + struct SearchResponse { + results: Vec, + } + let url = self.base("/search/movie"); + let mut extra = vec![("query", title.value())]; + let year_str; + if let Some(y) = year { + year_str = y.value().to_string(); + extra.push(("year", year_str.as_str())); + } + let resp: SearchResponse = self.get(&url, &extra).await?; + resp.results + .into_iter() + .next() + .ok_or_else(|| { + DomainError::NotFound(format!("TMDB: no results for '{}'", title.value())) + })? + .id + } + }; + + self.fetch_details(tmdb_id).await + } +} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index c776203..4408a5d 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -71,13 +71,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let auth_config = AuthConfig::from_env()?; let storage_config = StorageConfig::from_env()?; let app_config = AppConfig::from_env(); - let omdb_api_key = std::env::var("OMDB_API_KEY").context("OMDB_API_KEY must be set")?; - let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); let metadata_client: Arc = - Arc::new(MetadataClientImpl::new_omdb(omdb_api_key)); + if let Ok(tmdb_key) = std::env::var("TMDB_API_KEY") { + Arc::new(MetadataClientImpl::new_tmdb(tmdb_key)) + } else { + let omdb_key = std::env::var("OMDB_API_KEY") + .context("Either TMDB_API_KEY or OMDB_API_KEY must be set")?; + Arc::new(MetadataClientImpl::new_omdb(omdb_key)) + }; let poster_fetcher: Arc = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?); let poster_storage: Arc =