feat: implement TMDb enrichment for movie profiles

- Add SqliteMovieProfileRepository for managing movie profiles in SQLite.
- Create TmdbEnrichmentClient to fetch movie details from TMDb API.
- Implement enrichment event handling with EnrichmentHandler.
- Introduce periodic jobs for cleaning up expired import sessions and checking for stale movie profiles.
- Update application context to include movie profile repository.
- Add API endpoint to retrieve movie profiles.
- Extend domain models with new structures for movie enrichment (Genre, Keyword, CastMember, CrewMember, MovieProfile).
- Modify event system to include MovieEnrichmentRequested event.
- Enhance tests to cover new functionality and ensure stability.
This commit is contained in:
2026-05-12 13:23:41 +02:00
parent c696a3b780
commit 38d13fbff1
30 changed files with 1193 additions and 30 deletions

View File

@@ -37,6 +37,10 @@ pub enum DomainEvent {
review_id: ReviewId,
user_id: UserId,
},
MovieEnrichmentRequested {
movie_id: MovieId,
external_metadata_id: String,
},
}
#[async_trait]

View File

@@ -1,4 +1,4 @@
use chrono::{NaiveDateTime, Utc};
use chrono::{DateTime, NaiveDateTime, Utc};
use crate::{
errors::DomainError,
@@ -490,3 +490,56 @@ mod tests {
assert_eq!(user.avatar_path(), None);
}
}
// ── Movie enrichment ───────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct Genre {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct Keyword {
pub tmdb_id: u32,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct CastMember {
pub tmdb_person_id: u64,
pub name: String,
pub character: String,
pub billing_order: u32,
pub profile_path: Option<String>,
}
#[derive(Clone, Debug)]
pub struct CrewMember {
pub tmdb_person_id: u64,
pub name: String,
pub job: String,
pub department: String,
pub profile_path: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MovieProfile {
pub movie_id: MovieId,
pub tmdb_id: u64,
pub imdb_id: Option<String>,
pub overview: Option<String>,
pub tagline: Option<String>,
pub runtime_minutes: Option<u32>,
pub budget_usd: Option<i64>,
pub revenue_usd: Option<i64>,
pub vote_average: Option<f64>,
pub vote_count: Option<u32>,
pub original_language: Option<String>,
pub collection_name: Option<String>,
pub genres: Vec<Genre>,
pub keywords: Vec<Keyword>,
pub cast: Vec<CastMember>,
pub crew: Vec<CrewMember>,
pub enriched_at: DateTime<Utc>,
}

View File

@@ -6,8 +6,8 @@ use crate::{
events::{DomainEvent, EventEnvelope},
models::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieStats, ParsedFile,
Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated},
},
value_objects::{
@@ -217,6 +217,31 @@ pub trait EventHandler: Send + Sync {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
#[async_trait]
pub trait PeriodicJob: Send + Sync {
fn interval(&self) -> std::time::Duration;
async fn run(&self) -> Result<(), DomainError>;
}
#[async_trait]
pub trait MovieProfileRepository: Send + Sync {
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
/// (enriched_at older than 30 days).
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
}
#[async_trait]
pub trait MovieEnrichmentClient: Send + Sync {
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
async fn fetch_profile(
&self,
movie_id: MovieId,
external_metadata_id: &str,
) -> Result<MovieProfile, DomainError>;
}
#[async_trait]
pub trait ImportSessionRepository: Send + Sync {
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;