domain: add person enrichment fields, event, port

This commit is contained in:
2026-06-11 13:30:19 +02:00
parent 549923b92e
commit 7df24a19ee
15 changed files with 198 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::PersonId,
value_objects::{ value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
}, },
@@ -109,6 +110,10 @@ pub enum EventPayload {
user_id: String, user_id: String,
year: u16, year: u16,
}, },
PersonEnrichmentRequested {
person_id: String,
external_person_id: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -135,6 +140,7 @@ impl EventPayload {
EventPayload::GoalCreated { .. } => "GoalCreated", EventPayload::GoalCreated { .. } => "GoalCreated",
EventPayload::GoalUpdated { .. } => "GoalUpdated", EventPayload::GoalUpdated { .. } => "GoalUpdated",
EventPayload::GoalDeleted { .. } => "GoalDeleted", EventPayload::GoalDeleted { .. } => "GoalDeleted",
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
} }
} }
} }
@@ -311,6 +317,13 @@ impl From<&DomainEvent> for EventPayload {
user_id: user_id.value().to_string(), user_id: user_id.value().to_string(),
year: *year, 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<EventPayload> for DomainEvent {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year, 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,
}),
} }
} }
} }

View File

@@ -23,6 +23,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::GoalCreated { .. } => "goal.created", DomainEvent::GoalCreated { .. } => "goal.created",
DomainEvent::GoalUpdated { .. } => "goal.updated", DomainEvent::GoalUpdated { .. } => "goal.updated",
DomainEvent::GoalDeleted { .. } => "goal.deleted", DomainEvent::GoalDeleted { .. } => "goal.deleted",
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
}; };
format!("{prefix}.{suffix}") format!("{prefix}.{suffix}")
} }

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, models::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
PersonId,
},
ports::{PersonCommand, PersonQuery}, ports::{PersonCommand, PersonQuery},
value_objects::MovieId, value_objects::MovieId,
}; };
@@ -111,6 +114,14 @@ impl PersonCommand for PostgresPersonAdapter {
} }
Ok((count, has_more)) Ok((count, has_more))
} }
async fn update_enrichment(
&self,
_id: &PersonId,
_data: &PersonEnrichmentData,
) -> Result<(), DomainError> {
todo!("person enrichment persistence")
}
} }
#[async_trait] #[async_trait]
@@ -135,7 +146,7 @@ impl PersonQuery for PostgresPersonAdapter {
Ok(row.map(|r| { Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id); let ext = ExternalPersonId::new(r.external_id);
Person::new( Person::basic(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext, ext,
r.name, r.name,
@@ -168,7 +179,7 @@ impl PersonQuery for PostgresPersonAdapter {
Ok(row.map(|r| { Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id); let ext = ExternalPersonId::new(r.external_id);
Person::new( Person::basic(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext, ext,
r.name, r.name,
@@ -283,7 +294,7 @@ impl PersonQuery for PostgresPersonAdapter {
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let ext = ExternalPersonId::new(r.external_id); let ext = ExternalPersonId::new(r.external_id);
Person::new( Person::basic(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext, ext,
r.name, r.name,

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId}, models::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
PersonId,
},
ports::{PersonCommand, PersonQuery}, ports::{PersonCommand, PersonQuery},
value_objects::MovieId, value_objects::MovieId,
}; };
@@ -111,6 +114,14 @@ impl PersonCommand for SqlitePersonAdapter {
} }
Ok((count, has_more)) Ok((count, has_more))
} }
async fn update_enrichment(
&self,
_id: &PersonId,
_data: &PersonEnrichmentData,
) -> Result<(), DomainError> {
todo!("person enrichment persistence")
}
} }
#[async_trait] #[async_trait]
@@ -259,7 +270,7 @@ struct PersonRow {
impl PersonRow { impl PersonRow {
fn into_person(self) -> Person { fn into_person(self) -> Person {
let ext = ExternalPersonId::new(self.external_id); let ext = ExternalPersonId::new(self.external_id);
Person::new( Person::basic(
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
ext, ext,
self.name, self.name,

View File

@@ -46,7 +46,7 @@ async fn pool_with_schema() -> SqlitePool {
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person { fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}")); let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
Person::new( Person::basic(
PersonId::from_external(&ext), PersonId::from_external(&ext),
ext, ext,
name.to_string(), name.to_string(),

View File

@@ -67,7 +67,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
for member in cast { for member in cast {
seen.entry(member.tmdb_person_id).or_insert_with(|| { seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id)); let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new( Person::basic(
PersonId::from_external(&ext), PersonId::from_external(&ext),
ext, ext,
member.name.clone(), member.name.clone(),
@@ -80,7 +80,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
for member in crew { for member in crew {
seen.entry(member.tmdb_person_id).or_insert_with(|| { seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id)); let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new( Person::basic(
PersonId::from_external(&ext), PersonId::from_external(&ext),
ext, ext,
member.name.clone(), member.name.clone(),

View File

@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
models::PersonId,
value_objects::{ value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
}, },
@@ -43,6 +44,10 @@ pub enum DomainEvent {
movie_id: MovieId, movie_id: MovieId,
external_metadata_id: String, external_metadata_id: String,
}, },
PersonEnrichmentRequested {
person_id: PersonId,
external_person_id: String,
},
ImageStored { ImageStored {
key: String, key: String,
}, },

View File

@@ -43,7 +43,9 @@ pub use import::{
}; };
pub use import_profile::ImportProfile; pub use import_profile::ImportProfile;
pub use import_session::ImportSession; 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::{ pub use search::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
SearchResults, SearchResults,

View File

@@ -46,15 +46,56 @@ pub struct Person {
name: String, name: String,
known_for_department: Option<String>, known_for_department: Option<String>,
profile_path: Option<String>, profile_path: Option<String>,
biography: Option<String>,
birthday: Option<chrono::NaiveDate>,
deathday: Option<chrono::NaiveDate>,
place_of_birth: Option<String>,
also_known_as: Vec<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
} }
impl Person { impl Person {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
id: PersonId, id: PersonId,
external_id: ExternalPersonId, external_id: ExternalPersonId,
name: String, name: String,
known_for_department: Option<String>, known_for_department: Option<String>,
profile_path: Option<String>, profile_path: Option<String>,
biography: Option<String>,
birthday: Option<chrono::NaiveDate>,
deathday: Option<chrono::NaiveDate>,
place_of_birth: Option<String>,
also_known_as: Vec<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
) -> 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<String>,
profile_path: Option<String>,
) -> Self { ) -> Self {
Self { Self {
id, id,
@@ -62,6 +103,14 @@ impl Person {
name, name,
known_for_department, known_for_department,
profile_path, 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> { pub fn profile_path(&self) -> Option<&str> {
self.profile_path.as_deref() self.profile_path.as_deref()
} }
pub fn biography(&self) -> Option<&str> {
self.biography.as_deref()
}
pub fn birthday(&self) -> Option<chrono::NaiveDate> {
self.birthday
}
pub fn deathday(&self) -> Option<chrono::NaiveDate> {
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<chrono::DateTime<chrono::Utc>> {
self.enriched_at
}
}
#[derive(Clone, Debug)]
pub struct PersonEnrichmentData {
pub biography: Option<String>,
pub birthday: Option<chrono::NaiveDate>,
pub deathday: Option<chrono::NaiveDate>,
pub place_of_birth: Option<String>,
pub also_known_as: Vec<String>,
pub homepage: Option<String>,
pub imdb_id: Option<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -4,7 +4,7 @@ use super::*;
fn person_new() { fn person_new() {
let ext = ExternalPersonId::new("tmdb:12345"); let ext = ExternalPersonId::new("tmdb:12345");
let pid = PersonId::from_external(&ext); let pid = PersonId::from_external(&ext);
let p = Person::new( let p = Person::basic(
pid, pid,
ext, ext,
"Keanu Reeves".into(), "Keanu Reeves".into(),
@@ -38,7 +38,7 @@ fn person_id_deterministic() {
fn person_credits_default_empty() { fn person_credits_default_empty() {
let ext = ExternalPersonId::new("tmdb:1"); let ext = ExternalPersonId::new("tmdb:1");
let pid = PersonId::from_external(&ext); 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 { let credits = PersonCredits {
person: p, person: p,
cast: vec![], cast: vec![],

View File

@@ -10,9 +10,9 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession, FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile, IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteGoalEntry, ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId,
RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, User, RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults,
UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus, User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
WatchlistEntry, WatchlistWithMovie, WebhookToken, WatchlistEntry, WatchlistWithMovie, WebhookToken,
collections::{self, PageParams, Paginated}, collections::{self, PageParams, Paginated},
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus}, wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
@@ -292,6 +292,14 @@ pub trait MovieEnrichmentClient: Send + Sync {
) -> Result<MovieProfile, DomainError>; ) -> Result<MovieProfile, DomainError>;
} }
#[async_trait]
pub trait PersonEnrichmentClient: Send + Sync {
async fn fetch_details(
&self,
external_id: &str,
) -> Result<PersonEnrichmentData, DomainError>;
}
#[async_trait] #[async_trait]
pub trait ImportSessionRepository: Send + Sync { pub trait ImportSessionRepository: Send + Sync {
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
@@ -339,6 +347,11 @@ pub trait PersonCommand: Send + Sync {
&self, &self,
batch_size: u32, batch_size: u32,
) -> Result<(u64, bool), DomainError>; ) -> Result<(u64, bool), DomainError>;
async fn update_enrichment(
&self,
id: &PersonId,
data: &PersonEnrichmentData,
) -> Result<(), DomainError>;
} }
/// Read port — queries persons and credits. No mutations. /// Read port — queries persons and credits. No mutations.

View File

@@ -223,7 +223,7 @@ impl PersonQuery for FakePersonQuery {
} }
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> { async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let dummy = Person::new( let dummy = Person::basic(
id.clone(), id.clone(),
ExternalPersonId::new("tmdb:0"), ExternalPersonId::new("tmdb:0"),
"Unknown".into(), "Unknown".into(),

View File

@@ -5,7 +5,8 @@ use crate::{
models::{ models::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, 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, ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
@@ -150,6 +151,13 @@ impl PersonCommand for PanicPersonCommand {
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> { async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
panic!("PanicPersonCommand called") panic!("PanicPersonCommand called")
} }
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!("PanicPersonCommand called")
}
} }
pub struct PanicPersonQuery; pub struct PanicPersonQuery;

View File

@@ -14,7 +14,8 @@ use domain::{
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person, DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats, PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
SearchResults, UserStats,
UserTrends, UserTrends,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
@@ -437,6 +438,13 @@ impl PersonCommand for Panic {
) -> Result<(u64, bool), DomainError> { ) -> Result<(u64, bool), DomainError> {
panic!() panic!()
} }
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl PersonQuery for Panic { impl PersonQuery for Panic {

View File

@@ -14,7 +14,8 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId, EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits,
PersonEnrichmentData, PersonId,
SearchQuery, SearchResults, User, SearchQuery, SearchResults, User,
}, },
ports::{ ports::{
@@ -314,6 +315,13 @@ impl PersonCommand for PanicPersonCommand {
) -> Result<(u64, bool), DomainError> { ) -> Result<(u64, bool), DomainError> {
panic!() panic!()
} }
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!()
}
} }
struct PanicPersonQuery; struct PanicPersonQuery;