From a68e19aad7bad721ab2ff76411da91099bed4181 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 11 Jun 2026 13:38:07 +0200 Subject: [PATCH] feat: TMDB person enrichment client + event handler --- crates/adapters/tmdb-enrichment/src/lib.rs | 105 ++++++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/crates/adapters/tmdb-enrichment/src/lib.rs b/crates/adapters/tmdb-enrichment/src/lib.rs index bb11ddb..c940cf5 100644 --- a/crates/adapters/tmdb-enrichment/src/lib.rs +++ b/crates/adapters/tmdb-enrichment/src/lib.rs @@ -6,10 +6,10 @@ use chrono::Utc; use domain::{ errors::DomainError, events::DomainEvent, - models::{CastMember, CrewMember, Genre, Keyword, MovieProfile}, + models::{CastMember, CrewMember, Genre, Keyword, MovieProfile, PersonEnrichmentData}, ports::{ EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository, - ObjectStorage, PersonCommand, SearchCommand, + ObjectStorage, PersonCommand, PersonEnrichmentClient, PersonQuery, SearchCommand, }, value_objects::MovieId, }; @@ -221,7 +221,51 @@ impl MovieEnrichmentClient for TmdbEnrichmentClient { } } -// ── Enrichment event handler ───────────────────────────────────────────────── +// ── Person enrichment client ──────────────────────────────────────────────── + +#[async_trait] +impl PersonEnrichmentClient for TmdbEnrichmentClient { + async fn fetch_details(&self, external_id: &str) -> Result { + let tmdb_id = external_id + .strip_prefix("tmdb:") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + DomainError::InfrastructureError(format!( + "Cannot parse person external_id: {external_id}" + )) + })?; + + #[derive(Deserialize)] + struct PersonDetails { + biography: Option, + birthday: Option, + deathday: Option, + place_of_birth: Option, + also_known_as: Option>, + homepage: Option, + imdb_id: Option, + } + + let url = self.base(&format!("/person/{tmdb_id}")); + let d: PersonDetails = self.get(&url, &[]).await?; + + Ok(PersonEnrichmentData { + biography: d.biography.filter(|s| !s.is_empty()), + birthday: d + .birthday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()), + deathday: d + .deathday + .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()), + place_of_birth: d.place_of_birth.filter(|s| !s.is_empty()), + also_known_as: d.also_known_as.unwrap_or_default(), + homepage: d.homepage.filter(|s| !s.is_empty()), + imdb_id: d.imdb_id.filter(|s| !s.is_empty()), + }) + } +} + +// ── Movie enrichment event handler ────────────────────────────────────────── pub struct EnrichmentHandler { pub enrichment_client: Arc, @@ -310,3 +354,58 @@ impl EventHandler for EnrichmentHandler { .await } } + +// ── Person enrichment event handler ───────────────────────────────────────── + +pub struct PersonEnrichmentHandler { + enrichment_client: Arc, + person_query: Arc, + person_command: Arc, +} + +impl PersonEnrichmentHandler { + pub fn new( + enrichment_client: Arc, + person_query: Arc, + person_command: Arc, + ) -> Self { + Self { + enrichment_client, + person_query, + person_command, + } + } +} + +const PERSON_STALENESS_DAYS: i64 = 90; + +#[async_trait] +impl EventHandler for PersonEnrichmentHandler { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + let (person_id, external_person_id) = match event { + DomainEvent::PersonEnrichmentRequested { + person_id, + external_person_id, + } => (person_id.clone(), external_person_id.clone()), + _ => return Ok(()), + }; + + if let Some(person) = self.person_query.get_by_id(&person_id).await? { + if let Some(at) = person.enriched_at() { + if (Utc::now() - at).num_days() < PERSON_STALENESS_DAYS { + tracing::debug!(person_id = %person_id.value(), "person enrichment still fresh"); + return Ok(()); + } + } + } + + tracing::info!(person_id = %person_id.value(), "enriching person from TMDb"); + let data = self + .enrichment_client + .fetch_details(&external_person_id) + .await?; + self.person_command + .update_enrichment(&person_id, &data) + .await + } +}