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

@@ -4,7 +4,7 @@ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImageStorage,
ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
ReviewRepository, StatsRepository, UserRepository,
};
@@ -27,5 +27,6 @@ pub struct AppContext {
pub user_repository: Arc<dyn UserRepository>,
pub import_session_repository: Arc<dyn ImportSessionRepository>,
pub import_profile_repository: Arc<dyn ImportProfileRepository>,
pub movie_profile_repository: Arc<dyn MovieProfileRepository>,
pub config: AppConfig,
}

View File

@@ -0,0 +1,60 @@
use std::time::Duration;
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
use crate::context::AppContext;
pub struct ImportSessionCleanupJob {
ctx: AppContext,
}
impl ImportSessionCleanupJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for ImportSessionCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let n = crate::use_cases::cleanup_expired_import_sessions::execute(&self.ctx).await?;
tracing::info!("import session cleanup: removed {} expired sessions", n);
Ok(())
}
}
pub struct EnrichmentStalenessJob {
ctx: AppContext,
}
impl EnrichmentStalenessJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for EnrichmentStalenessJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let stale = self.ctx.movie_profile_repository.list_stale().await?;
if stale.is_empty() {
return Ok(());
}
tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale {
let event = DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id };
self.ctx.event_publisher.publish(&event).await?;
}
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
pub mod commands;
pub mod jobs;
pub mod worker;
pub mod config;
pub mod context;

View File

@@ -50,6 +50,14 @@ async fn publish_events(
}
}
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.value().to_string(),
};
ctx.event_publisher.publish(&enrichment_event).await?;
}
ctx.event_publisher.publish(&review_event).await?;
Ok(())
}

View File

@@ -96,6 +96,7 @@ mod tests {
DomainEvent::ReviewDeleted { .. } => "review_deleted",
DomainEvent::MovieDeleted { .. } => "movie_deleted",
DomainEvent::UserUpdated { .. } => "user_updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested",
};
self.calls.lock().unwrap().push(label);
Ok(())