diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 77e11d5..4d4a98f 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -2,6 +2,7 @@ use chrono::NaiveDateTime; use domain::{ errors::DomainError, events::DomainEvent, + models::PersonId, value_objects::{ ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, }, @@ -109,6 +110,10 @@ pub enum EventPayload { user_id: String, year: u16, }, + PersonEnrichmentRequested { + person_id: String, + external_person_id: String, + }, } impl EventPayload { @@ -135,6 +140,7 @@ impl EventPayload { EventPayload::GoalCreated { .. } => "GoalCreated", EventPayload::GoalUpdated { .. } => "GoalUpdated", EventPayload::GoalDeleted { .. } => "GoalDeleted", + EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested", } } } @@ -311,6 +317,13 @@ impl From<&DomainEvent> for EventPayload { user_id: user_id.value().to_string(), year: *year, }, + DomainEvent::PersonEnrichmentRequested { + person_id, + external_person_id, + } => EventPayload::PersonEnrichmentRequested { + person_id: person_id.value().to_string(), + external_person_id: external_person_id.clone(), + }, } } } @@ -496,6 +509,13 @@ impl TryFrom for DomainEvent { user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), year, }), + EventPayload::PersonEnrichmentRequested { + person_id, + external_person_id, + } => Ok(DomainEvent::PersonEnrichmentRequested { + person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?), + external_person_id, + }), } } } diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index 3683c31..0f4c480 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -23,6 +23,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { DomainEvent::GoalCreated { .. } => "goal.created", DomainEvent::GoalUpdated { .. } => "goal.updated", DomainEvent::GoalDeleted { .. } => "goal.deleted", + DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested", }; format!("{prefix}.{suffix}") } diff --git a/crates/adapters/postgres/src/persons.rs b/crates/adapters/postgres/src/persons.rs index 978900e..8251549 100644 --- a/crates/adapters/postgres/src/persons.rs +++ b/crates/adapters/postgres/src/persons.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, + models::{ + CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, + PersonId, + }, ports::{PersonCommand, PersonQuery}, value_objects::MovieId, }; @@ -111,6 +114,14 @@ impl PersonCommand for PostgresPersonAdapter { } Ok((count, has_more)) } + + async fn update_enrichment( + &self, + _id: &PersonId, + _data: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + todo!("person enrichment persistence") + } } #[async_trait] @@ -135,7 +146,7 @@ impl PersonQuery for PostgresPersonAdapter { Ok(row.map(|r| { let ext = ExternalPersonId::new(r.external_id); - Person::new( + Person::basic( PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), ext, r.name, @@ -168,7 +179,7 @@ impl PersonQuery for PostgresPersonAdapter { Ok(row.map(|r| { let ext = ExternalPersonId::new(r.external_id); - Person::new( + Person::basic( PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), ext, r.name, @@ -283,7 +294,7 @@ impl PersonQuery for PostgresPersonAdapter { .into_iter() .map(|r| { let ext = ExternalPersonId::new(r.external_id); - Person::new( + Person::basic( PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), ext, r.name, diff --git a/crates/adapters/sqlite/src/persons.rs b/crates/adapters/sqlite/src/persons.rs index 04d6be5..07b4bde 100644 --- a/crates/adapters/sqlite/src/persons.rs +++ b/crates/adapters/sqlite/src/persons.rs @@ -1,7 +1,10 @@ use async_trait::async_trait; use domain::{ errors::DomainError, - models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, + models::{ + CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, + PersonId, + }, ports::{PersonCommand, PersonQuery}, value_objects::MovieId, }; @@ -111,6 +114,14 @@ impl PersonCommand for SqlitePersonAdapter { } Ok((count, has_more)) } + + async fn update_enrichment( + &self, + _id: &PersonId, + _data: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + todo!("person enrichment persistence") + } } #[async_trait] @@ -259,7 +270,7 @@ struct PersonRow { impl PersonRow { fn into_person(self) -> Person { let ext = ExternalPersonId::new(self.external_id); - Person::new( + Person::basic( PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), ext, self.name, diff --git a/crates/adapters/sqlite/src/tests/persons.rs b/crates/adapters/sqlite/src/tests/persons.rs index 569f1d8..0fe444a 100644 --- a/crates/adapters/sqlite/src/tests/persons.rs +++ b/crates/adapters/sqlite/src/tests/persons.rs @@ -46,7 +46,7 @@ async fn pool_with_schema() -> SqlitePool { fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person { let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}")); - Person::new( + Person::basic( PersonId::from_external(&ext), ext, name.to_string(), diff --git a/crates/application/src/movies/enrich_movie.rs b/crates/application/src/movies/enrich_movie.rs index b118802..71eb5f9 100644 --- a/crates/application/src/movies/enrich_movie.rs +++ b/crates/application/src/movies/enrich_movie.rs @@ -67,7 +67,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec { for member in cast { seen.entry(member.tmdb_person_id).or_insert_with(|| { let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id)); - Person::new( + Person::basic( PersonId::from_external(&ext), ext, member.name.clone(), @@ -80,7 +80,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec { for member in crew { seen.entry(member.tmdb_person_id).or_insert_with(|| { let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id)); - Person::new( + Person::basic( PersonId::from_external(&ext), ext, member.name.clone(), diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 67397f8..826c54a 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -3,6 +3,7 @@ use chrono::NaiveDateTime; use crate::{ errors::DomainError, + models::PersonId, value_objects::{ ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, }, @@ -43,6 +44,10 @@ pub enum DomainEvent { movie_id: MovieId, external_metadata_id: String, }, + PersonEnrichmentRequested { + person_id: PersonId, + external_person_id: String, + }, ImageStored { key: String, }, diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 67ddb06..e7c9cd0 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -43,7 +43,9 @@ pub use import::{ }; pub use import_profile::ImportProfile; pub use import_session::ImportSession; -pub use person::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}; +pub use person::{ + CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId, +}; pub use search::{ EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery, SearchResults, diff --git a/crates/domain/src/models/person.rs b/crates/domain/src/models/person.rs index f907cf7..48ee2ca 100644 --- a/crates/domain/src/models/person.rs +++ b/crates/domain/src/models/person.rs @@ -46,15 +46,56 @@ pub struct Person { name: String, known_for_department: Option, profile_path: Option, + biography: Option, + birthday: Option, + deathday: Option, + place_of_birth: Option, + also_known_as: Vec, + homepage: Option, + imdb_id: Option, + enriched_at: Option>, } impl Person { + #[allow(clippy::too_many_arguments)] pub fn new( id: PersonId, external_id: ExternalPersonId, name: String, known_for_department: Option, profile_path: Option, + biography: Option, + birthday: Option, + deathday: Option, + place_of_birth: Option, + also_known_as: Vec, + homepage: Option, + imdb_id: Option, + enriched_at: Option>, + ) -> Self { + Self { + id, + external_id, + name, + known_for_department, + profile_path, + biography, + birthday, + deathday, + place_of_birth, + also_known_as, + homepage, + imdb_id, + enriched_at, + } + } + + pub fn basic( + id: PersonId, + external_id: ExternalPersonId, + name: String, + known_for_department: Option, + profile_path: Option, ) -> Self { Self { id, @@ -62,6 +103,14 @@ impl Person { name, known_for_department, profile_path, + biography: None, + birthday: None, + deathday: None, + place_of_birth: None, + also_known_as: vec![], + homepage: None, + imdb_id: None, + enriched_at: None, } } @@ -84,6 +133,49 @@ impl Person { pub fn profile_path(&self) -> Option<&str> { self.profile_path.as_deref() } + + pub fn biography(&self) -> Option<&str> { + self.biography.as_deref() + } + + pub fn birthday(&self) -> Option { + self.birthday + } + + pub fn deathday(&self) -> Option { + self.deathday + } + + pub fn place_of_birth(&self) -> Option<&str> { + self.place_of_birth.as_deref() + } + + pub fn also_known_as(&self) -> &[String] { + &self.also_known_as + } + + pub fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } + + pub fn imdb_id(&self) -> Option<&str> { + self.imdb_id.as_deref() + } + + pub fn enriched_at(&self) -> Option> { + self.enriched_at + } +} + +#[derive(Clone, Debug)] +pub struct PersonEnrichmentData { + pub biography: Option, + pub birthday: Option, + pub deathday: Option, + pub place_of_birth: Option, + pub also_known_as: Vec, + pub homepage: Option, + pub imdb_id: Option, } #[derive(Clone, Debug)] diff --git a/crates/domain/src/models/tests/person.rs b/crates/domain/src/models/tests/person.rs index a4114f9..2e11467 100644 --- a/crates/domain/src/models/tests/person.rs +++ b/crates/domain/src/models/tests/person.rs @@ -4,7 +4,7 @@ use super::*; fn person_new() { let ext = ExternalPersonId::new("tmdb:12345"); let pid = PersonId::from_external(&ext); - let p = Person::new( + let p = Person::basic( pid, ext, "Keanu Reeves".into(), @@ -38,7 +38,7 @@ fn person_id_deterministic() { fn person_credits_default_empty() { let ext = ExternalPersonId::new("tmdb:1"); let pid = PersonId::from_external(&ext); - let p = Person::new(pid, ext, "Test".into(), None, None); + let p = Person::basic(pid, ext, "Test".into(), None, None); let credits = PersonCredits { person: p, cast: vec![], diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5f728f1..8f27d67 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -10,9 +10,9 @@ use crate::{ AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile, - ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteGoalEntry, - RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, User, - UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus, + ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, + RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, + User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken, collections::{self, PageParams, Paginated}, wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus}, @@ -292,6 +292,14 @@ pub trait MovieEnrichmentClient: Send + Sync { ) -> Result; } +#[async_trait] +pub trait PersonEnrichmentClient: Send + Sync { + async fn fetch_details( + &self, + external_id: &str, + ) -> Result; +} + #[async_trait] pub trait ImportSessionRepository: Send + Sync { async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; @@ -339,6 +347,11 @@ pub trait PersonCommand: Send + Sync { &self, batch_size: u32, ) -> Result<(u64, bool), DomainError>; + async fn update_enrichment( + &self, + id: &PersonId, + data: &PersonEnrichmentData, + ) -> Result<(), DomainError>; } /// Read port — queries persons and credits. No mutations. diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes.rs index a1cb081..c36f340 100644 --- a/crates/domain/src/testing/fakes.rs +++ b/crates/domain/src/testing/fakes.rs @@ -223,7 +223,7 @@ impl PersonQuery for FakePersonQuery { } async fn get_credits(&self, id: &PersonId) -> Result { - let dummy = Person::new( + let dummy = Person::basic( id.clone(), ExternalPersonId::new("tmdb:0"), "Unknown".into(), diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index 2d3bb36..a475a36 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -5,7 +5,8 @@ use crate::{ models::{ AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, - IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId, + IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, + PersonEnrichmentData, PersonId, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, }, @@ -150,6 +151,13 @@ impl PersonCommand for PanicPersonCommand { async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> { panic!("PanicPersonCommand called") } + async fn update_enrichment( + &self, + _: &PersonId, + _: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + panic!("PanicPersonCommand called") + } } pub struct PanicPersonQuery; diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index ba14e36..f5c39bc 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -14,7 +14,8 @@ use domain::{ events::DomainEvent, models::{ DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person, - PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats, + PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery, + SearchResults, UserStats, UserTrends, collections::{PageParams, Paginated}, }, @@ -437,6 +438,13 @@ impl PersonCommand for Panic { ) -> Result<(u64, bool), DomainError> { panic!() } + async fn update_enrichment( + &self, + _: &PersonId, + _: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl PersonQuery for Panic { diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 1de48f5..7382f1c 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -14,7 +14,8 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId, + EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, + PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User, }, ports::{ @@ -314,6 +315,13 @@ impl PersonCommand for PanicPersonCommand { ) -> Result<(u64, bool), DomainError> { panic!() } + async fn update_enrichment( + &self, + _: &PersonId, + _: &PersonEnrichmentData, + ) -> Result<(), DomainError> { + panic!() + } } struct PanicPersonQuery;