refactor(movies): EnrichMovieDeps, ReindexSearchDeps, SyncPosterDeps, SearchReindexHandler, EnrichmentStalenessJob
This commit is contained in:
@@ -1,6 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use application::movies::{commands::EnrichMovieCommand, enrich_movie, request_enrichment};
|
use application::movies::{
|
||||||
|
commands::EnrichMovieCommand,
|
||||||
|
deps::EnrichMovieDeps,
|
||||||
|
enrich_movie,
|
||||||
|
request_enrichment,
|
||||||
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -89,13 +94,12 @@ impl EventHandler for MovieEnrichmentHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.download_cast_photos(&profile).await;
|
self.download_cast_photos(&profile).await;
|
||||||
enrich_movie::execute(
|
let enrich_deps = EnrichMovieDeps {
|
||||||
&self.movie_repository,
|
movie: self.movie_repository.clone(),
|
||||||
&self.profile_repo,
|
movie_profile: self.profile_repo.clone(),
|
||||||
&self.person_command,
|
person_command: self.person_command.clone(),
|
||||||
&self.search_command,
|
search_command: self.search_command.clone(),
|
||||||
EnrichMovieCommand { movie_id, profile },
|
};
|
||||||
)
|
enrich_movie::execute(&enrich_deps, EnrichMovieCommand { movie_id, profile }).await
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
use crate::context::AppContext;
|
events::DomainEvent,
|
||||||
|
ports::{EventPublisher, MovieProfileRepository, PeriodicJob},
|
||||||
|
};
|
||||||
|
|
||||||
pub struct EnrichmentStalenessJob {
|
pub struct EnrichmentStalenessJob {
|
||||||
ctx: AppContext,
|
movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EnrichmentStalenessJob {
|
impl EnrichmentStalenessJob {
|
||||||
pub fn new(ctx: AppContext) -> Self {
|
pub fn new(
|
||||||
Self { ctx }
|
movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
movie_profile,
|
||||||
|
event_publisher,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +32,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self) -> Result<(), DomainError> {
|
async fn run(&self) -> Result<(), DomainError> {
|
||||||
let stale = self.ctx.repos.movie_profile.list_stale().await?;
|
let stale = self.movie_profile.list_stale().await?;
|
||||||
if stale.is_empty() {
|
if stale.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -32,7 +42,7 @@ impl PeriodicJob for EnrichmentStalenessJob {
|
|||||||
movie_id,
|
movie_id,
|
||||||
external_metadata_id,
|
external_metadata_id,
|
||||||
};
|
};
|
||||||
self.ctx.services.event_publisher.publish(&event).await?;
|
self.event_publisher.publish(&event).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
39
crates/application/src/movies/deps.rs
Normal file
39
crates/application/src/movies/deps.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{
|
||||||
|
EventPublisher, MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage,
|
||||||
|
PersonCommand, PersonQuery, PosterFetcherClient, SearchCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct GetMoviesDeps {
|
||||||
|
pub movie: Arc<dyn MovieRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetMovieProfileDeps {
|
||||||
|
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SyncPosterDeps {
|
||||||
|
pub movie: Arc<dyn MovieRepository>,
|
||||||
|
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
pub metadata: Arc<dyn MetadataClient>,
|
||||||
|
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
|
pub object_storage: Arc<dyn ObjectStorage>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EnrichMovieDeps {
|
||||||
|
pub movie: Arc<dyn MovieRepository>,
|
||||||
|
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
pub person_command: Arc<dyn PersonCommand>,
|
||||||
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReindexSearchDeps {
|
||||||
|
pub movie: Arc<dyn MovieRepository>,
|
||||||
|
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||||
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
|
pub person_command: Arc<dyn PersonCommand>,
|
||||||
|
pub person_query: Arc<dyn PersonQuery>,
|
||||||
|
}
|
||||||
@@ -1,38 +1,30 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId},
|
models::{CastMember, CrewMember, ExternalPersonId, IndexableDocument, Person, PersonId},
|
||||||
ports::{MovieProfileRepository, MovieRepository, PersonCommand, SearchCommand},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::movies::commands::EnrichMovieCommand;
|
use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(deps: &EnrichMovieDeps, cmd: EnrichMovieCommand) -> Result<(), DomainError> {
|
||||||
movie_repository: &Arc<dyn MovieRepository>,
|
|
||||||
profile_repository: &Arc<dyn MovieProfileRepository>,
|
|
||||||
person_command: &Arc<dyn PersonCommand>,
|
|
||||||
search_command: &Arc<dyn SearchCommand>,
|
|
||||||
cmd: EnrichMovieCommand,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
// 1. Persist the enriched profile (also handles movie_cast, movie_crew, genres, keywords)
|
// 1. Persist the enriched profile (also handles movie_cast, movie_crew, genres, keywords)
|
||||||
profile_repository.upsert(&cmd.profile).await?;
|
deps.movie_profile.upsert(&cmd.profile).await?;
|
||||||
|
|
||||||
// 2. Upsert persons extracted from cast + crew (no reads — only upsert)
|
// 2. Upsert persons extracted from cast + crew (no reads — only upsert)
|
||||||
let persons = extract_persons(&cmd.profile.cast, &cmd.profile.crew);
|
let persons = extract_persons(&cmd.profile.cast, &cmd.profile.crew);
|
||||||
if !persons.is_empty() {
|
if !persons.is_empty() {
|
||||||
person_command.upsert_batch(&persons).await?;
|
deps.person_command.upsert_batch(&persons).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch the movie for the search index document
|
// 3. Fetch the movie for the search index document
|
||||||
let Some(movie) = movie_repository.get_movie_by_id(&cmd.movie_id).await? else {
|
let Some(movie) = deps.movie.get_movie_by_id(&cmd.movie_id).await? else {
|
||||||
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert");
|
tracing::warn!(movie_id = %cmd.movie_id.value(), "enrich_movie: movie not found after profile upsert");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Index the movie in search
|
// 4. Index the movie in search
|
||||||
search_command
|
deps.search_command
|
||||||
.index(IndexableDocument::Movie {
|
.index(IndexableDocument::Movie {
|
||||||
id: cmd.movie_id.clone(),
|
id: cmd.movie_id.clone(),
|
||||||
movie: Box::new(movie),
|
movie: Box::new(movie),
|
||||||
@@ -42,7 +34,7 @@ pub async fn execute(
|
|||||||
|
|
||||||
// 5. Index each unique person in search (no reads — persons built from in-memory data)
|
// 5. Index each unique person in search (no reads — persons built from in-memory data)
|
||||||
for person in &persons {
|
for person in &persons {
|
||||||
search_command
|
deps.search_command
|
||||||
.index(IndexableDocument::Person {
|
.index(IndexableDocument::Person {
|
||||||
id: person.id().clone(),
|
id: person.id().clone(),
|
||||||
person: Box::new(person.clone()),
|
person: Box::new(person.clone()),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::context::AppContext;
|
use crate::movies::deps::GetMovieProfileDeps;
|
||||||
|
|
||||||
pub struct GetMovieProfileQuery {
|
pub struct GetMovieProfileQuery {
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
@@ -60,11 +60,11 @@ fn resolve_crew(member: &CrewMember) -> CrewMemberWithId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
ctx: &AppContext,
|
deps: &GetMovieProfileDeps,
|
||||||
query: GetMovieProfileQuery,
|
query: GetMovieProfileQuery,
|
||||||
) -> Result<Option<MovieProfileResult>, DomainError> {
|
) -> Result<Option<MovieProfileResult>, DomainError> {
|
||||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||||
let profile = ctx.repos.movie_profile.get_by_movie_id(&movie_id).await?;
|
let profile = deps.movie_profile.get_by_movie_id(&movie_id).await?;
|
||||||
|
|
||||||
Ok(profile.map(|p| {
|
Ok(profile.map(|p| {
|
||||||
let cast = p.cast.iter().map(resolve_cast).collect();
|
let cast = p.cast.iter().map(resolve_cast).collect();
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ use domain::{
|
|||||||
models::{MovieFilter, MovieSummary},
|
models::{MovieFilter, MovieSummary},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{context::AppContext, movies::queries::GetMoviesQuery};
|
use crate::movies::{deps::GetMoviesDeps, queries::GetMoviesQuery};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
ctx: &AppContext,
|
deps: &GetMoviesDeps,
|
||||||
query: GetMoviesQuery,
|
query: GetMoviesQuery,
|
||||||
) -> Result<Paginated<MovieSummary>, DomainError> {
|
) -> Result<Paginated<MovieSummary>, DomainError> {
|
||||||
let page = PageParams::new(query.limit, query.offset)?;
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
@@ -16,7 +16,7 @@ pub async fn execute(
|
|||||||
genre: query.genre,
|
genre: query.genre,
|
||||||
language: query.language,
|
language: query.language,
|
||||||
};
|
};
|
||||||
ctx.repos.movie.list_movies(&page, &filter).await
|
deps.movie.list_movies(&page, &filter).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod deps;
|
||||||
pub mod discovery_indexer;
|
pub mod discovery_indexer;
|
||||||
pub mod enrich_movie;
|
pub mod enrich_movie;
|
||||||
pub mod get_movie_profile;
|
pub mod get_movie_profile;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::context::AppContext;
|
use crate::movies::deps::ReindexSearchDeps;
|
||||||
|
|
||||||
const BATCH_SIZE: u32 = 500;
|
const BATCH_SIZE: u32 = 500;
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ pub struct ReindexResult {
|
|||||||
pub persons_backfilled: u64,
|
pub persons_backfilled: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext) -> Result<ReindexResult, DomainError> {
|
pub async fn execute(deps: &ReindexSearchDeps) -> Result<ReindexResult, DomainError> {
|
||||||
let movies_indexed = reindex_movies(ctx).await?;
|
let movies_indexed = reindex_movies(deps).await?;
|
||||||
let persons_backfilled = backfill_persons(ctx).await?;
|
let persons_backfilled = backfill_persons(deps).await?;
|
||||||
let persons_indexed = reindex_persons(ctx).await?;
|
let persons_indexed = reindex_persons(deps).await?;
|
||||||
|
|
||||||
Ok(ReindexResult {
|
Ok(ReindexResult {
|
||||||
movies_indexed,
|
movies_indexed,
|
||||||
@@ -29,12 +29,11 @@ pub async fn execute(ctx: &AppContext) -> Result<ReindexResult, DomainError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reindex_movies(ctx: &AppContext) -> Result<u64, DomainError> {
|
async fn reindex_movies(deps: &ReindexSearchDeps) -> Result<u64, DomainError> {
|
||||||
let mut count: u64 = 0;
|
let mut count: u64 = 0;
|
||||||
let mut offset: u32 = 0;
|
let mut offset: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
let page = ctx
|
let page = deps
|
||||||
.repos
|
|
||||||
.movie
|
.movie
|
||||||
.list_movies(
|
.list_movies(
|
||||||
&PageParams {
|
&PageParams {
|
||||||
@@ -47,10 +46,9 @@ async fn reindex_movies(ctx: &AppContext) -> Result<u64, DomainError> {
|
|||||||
|
|
||||||
for summary in &page.items {
|
for summary in &page.items {
|
||||||
let movie_id = summary.movie.id().clone();
|
let movie_id = summary.movie.id().clone();
|
||||||
let profile = ctx.repos.movie_profile.get_by_movie_id(&movie_id).await?;
|
let profile = deps.movie_profile.get_by_movie_id(&movie_id).await?;
|
||||||
|
|
||||||
if let Err(e) = ctx
|
if let Err(e) = deps
|
||||||
.repos
|
|
||||||
.search_command
|
.search_command
|
||||||
.index(IndexableDocument::Movie {
|
.index(IndexableDocument::Movie {
|
||||||
id: movie_id.clone(),
|
id: movie_id.clone(),
|
||||||
@@ -73,11 +71,10 @@ async fn reindex_movies(ctx: &AppContext) -> Result<u64, DomainError> {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_persons(ctx: &AppContext) -> Result<u64, DomainError> {
|
async fn backfill_persons(deps: &ReindexSearchDeps) -> Result<u64, DomainError> {
|
||||||
let mut total = 0u64;
|
let mut total = 0u64;
|
||||||
loop {
|
loop {
|
||||||
let (count, has_more) = ctx
|
let (count, has_more) = deps
|
||||||
.repos
|
|
||||||
.person_command
|
.person_command
|
||||||
.backfill_from_credits_batch(BATCH_SIZE)
|
.backfill_from_credits_batch(BATCH_SIZE)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -90,15 +87,14 @@ async fn backfill_persons(ctx: &AppContext) -> Result<u64, DomainError> {
|
|||||||
Ok(total)
|
Ok(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reindex_persons(ctx: &AppContext) -> Result<u64, DomainError> {
|
async fn reindex_persons(deps: &ReindexSearchDeps) -> Result<u64, DomainError> {
|
||||||
let mut count: u64 = 0;
|
let mut count: u64 = 0;
|
||||||
let mut offset: u32 = 0;
|
let mut offset: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
let persons = ctx.repos.person_query.list_page(BATCH_SIZE, offset).await?;
|
let persons = deps.person_query.list_page(BATCH_SIZE, offset).await?;
|
||||||
|
|
||||||
for person in &persons {
|
for person in &persons {
|
||||||
if let Err(e) = ctx
|
if let Err(e) = deps
|
||||||
.repos
|
|
||||||
.search_command
|
.search_command
|
||||||
.index(IndexableDocument::Person {
|
.index(IndexableDocument::Person {
|
||||||
id: person.id().clone(),
|
id: person.id().clone(),
|
||||||
@@ -121,14 +117,14 @@ async fn reindex_persons(ctx: &AppContext) -> Result<u64, DomainError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct SearchReindexHandler {
|
pub struct SearchReindexHandler {
|
||||||
ctx: AppContext,
|
deps: ReindexSearchDeps,
|
||||||
running: AtomicBool,
|
running: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SearchReindexHandler {
|
impl SearchReindexHandler {
|
||||||
pub fn new(ctx: AppContext) -> Self {
|
pub fn new(deps: ReindexSearchDeps) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ctx,
|
deps,
|
||||||
running: AtomicBool::new(false),
|
running: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +143,7 @@ impl EventHandler for SearchReindexHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("search reindex started");
|
tracing::info!("search reindex started");
|
||||||
let result = execute(&self.ctx).await;
|
let result = execute(&self.deps).await;
|
||||||
self.running.store(false, Ordering::SeqCst);
|
self.running.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
let r = result?;
|
let r = result?;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ use domain::{
|
|||||||
value_objects::{MovieId, PosterPath},
|
value_objects::{MovieId, PosterPath},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{context::AppContext, diary::commands::SyncPosterCommand};
|
use crate::{diary::commands::SyncPosterCommand, movies::deps::SyncPosterDeps};
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
pub async fn execute(deps: &SyncPosterDeps, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||||
|
|
||||||
let mut movie = match ctx.repos.movie.get_movie_by_id(&movie_id).await? {
|
let mut movie = match deps.movie.get_movie_by_id(&movie_id).await? {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -30,8 +30,7 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
|||||||
})?
|
})?
|
||||||
.clone();
|
.clone();
|
||||||
|
|
||||||
let poster_url = match ctx
|
let poster_url = match deps
|
||||||
.services
|
|
||||||
.metadata
|
.metadata
|
||||||
.get_poster_url(&external_metadata_id)
|
.get_poster_url(&external_metadata_id)
|
||||||
.await
|
.await
|
||||||
@@ -44,20 +43,17 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let image_bytes = ctx
|
let image_bytes = deps
|
||||||
.services
|
|
||||||
.poster_fetcher
|
.poster_fetcher
|
||||||
.fetch_poster_bytes(&poster_url)
|
.fetch_poster_bytes(&poster_url)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let stored_path = ctx
|
let stored_path = deps
|
||||||
.services
|
|
||||||
.object_storage
|
.object_storage
|
||||||
.store(&movie_id.value().to_string(), &image_bytes)
|
.store(&movie_id.value().to_string(), &image_bytes)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Err(e) = ctx
|
if let Err(e) = deps
|
||||||
.services
|
|
||||||
.event_publisher
|
.event_publisher
|
||||||
.publish(&DomainEvent::ImageStored {
|
.publish(&DomainEvent::ImageStored {
|
||||||
key: stored_path.clone(),
|
key: stored_path.clone(),
|
||||||
@@ -70,19 +66,17 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
|||||||
let poster_path = PosterPath::new(stored_path)?;
|
let poster_path = PosterPath::new(stored_path)?;
|
||||||
|
|
||||||
movie.update_poster(poster_path);
|
movie.update_poster(poster_path);
|
||||||
ctx.repos.movie.upsert_movie(&movie).await?;
|
deps.movie.upsert_movie(&movie).await?;
|
||||||
|
|
||||||
// Refresh search index so the new poster_path is reflected immediately.
|
// Refresh search index so the new poster_path is reflected immediately.
|
||||||
// Fetch existing profile if available for a complete index document.
|
// Fetch existing profile if available for a complete index document.
|
||||||
let profile = ctx
|
let profile = deps
|
||||||
.repos
|
|
||||||
.movie_profile
|
.movie_profile
|
||||||
.get_by_movie_id(&movie_id)
|
.get_by_movie_id(&movie_id)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
if let Err(e) = ctx
|
if let Err(e) = deps
|
||||||
.repos
|
|
||||||
.search_command
|
.search_command
|
||||||
.index(IndexableDocument::Movie {
|
.index(IndexableDocument::Movie {
|
||||||
id: movie_id.clone(),
|
id: movie_id.clone(),
|
||||||
|
|||||||
@@ -11,15 +11,13 @@ use domain::{
|
|||||||
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
value_objects::{MovieId, MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::movies::{commands::EnrichMovieCommand, enrich_movie};
|
use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_movie};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stores_profile_and_indexes() {
|
async fn stores_profile_and_indexes() {
|
||||||
let movie_repo = InMemoryMovieRepository::new();
|
let movie_repo = InMemoryMovieRepository::new();
|
||||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||||
let search_cmd: Arc<dyn domain::ports::SearchCommand> = Arc::new(FakeSearchCommand);
|
|
||||||
// PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called
|
// PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called
|
||||||
let person_cmd: Arc<dyn domain::ports::PersonCommand> = Arc::new(PanicPersonCommand);
|
|
||||||
|
|
||||||
let movie = Movie::new(
|
let movie = Movie::new(
|
||||||
None,
|
None,
|
||||||
@@ -51,11 +49,15 @@ async fn stores_profile_and_indexes() {
|
|||||||
enriched_at: Utc::now(),
|
enriched_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let deps = EnrichMovieDeps {
|
||||||
|
movie: movie_repo as Arc<_>,
|
||||||
|
movie_profile: Arc::clone(&profile_repo) as Arc<_>,
|
||||||
|
person_command: Arc::new(PanicPersonCommand),
|
||||||
|
search_command: Arc::new(FakeSearchCommand),
|
||||||
|
};
|
||||||
|
|
||||||
enrich_movie::execute(
|
enrich_movie::execute(
|
||||||
&(movie_repo as Arc<_>),
|
&deps,
|
||||||
&(profile_repo.clone() as Arc<_>),
|
|
||||||
&person_cmd,
|
|
||||||
&search_cmd,
|
|
||||||
EnrichMovieCommand {
|
EnrichMovieCommand {
|
||||||
movie_id: movie_id.clone(),
|
movie_id: movie_id.clone(),
|
||||||
profile,
|
profile,
|
||||||
@@ -96,8 +98,6 @@ impl domain::ports::PersonCommand for NoopPersonCommand {
|
|||||||
async fn extracts_and_indexes_persons() {
|
async fn extracts_and_indexes_persons() {
|
||||||
let movie_repo = InMemoryMovieRepository::new();
|
let movie_repo = InMemoryMovieRepository::new();
|
||||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||||
let search_cmd: Arc<dyn domain::ports::SearchCommand> = Arc::new(FakeSearchCommand);
|
|
||||||
let person_cmd: Arc<dyn domain::ports::PersonCommand> = Arc::new(NoopPersonCommand);
|
|
||||||
|
|
||||||
let movie = Movie::new(
|
let movie = Movie::new(
|
||||||
None,
|
None,
|
||||||
@@ -141,11 +141,15 @@ async fn extracts_and_indexes_persons() {
|
|||||||
enriched_at: Utc::now(),
|
enriched_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let deps = EnrichMovieDeps {
|
||||||
|
movie: movie_repo as Arc<_>,
|
||||||
|
movie_profile: Arc::clone(&profile_repo) as Arc<_>,
|
||||||
|
person_command: Arc::new(NoopPersonCommand),
|
||||||
|
search_command: Arc::new(FakeSearchCommand),
|
||||||
|
};
|
||||||
|
|
||||||
enrich_movie::execute(
|
enrich_movie::execute(
|
||||||
&(movie_repo as Arc<_>),
|
&deps,
|
||||||
&(profile_repo.clone() as Arc<_>),
|
|
||||||
&person_cmd,
|
|
||||||
&search_cmd,
|
|
||||||
EnrichMovieCommand {
|
EnrichMovieCommand {
|
||||||
movie_id: movie_id.clone(),
|
movie_id: movie_id.clone(),
|
||||||
profile,
|
profile,
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ use domain::{
|
|||||||
value_objects::MovieId,
|
value_objects::MovieId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::movies::{
|
||||||
movies::get_movie_profile::{self, GetMovieProfileQuery},
|
deps::GetMovieProfileDeps,
|
||||||
test_helpers::TestContextBuilder,
|
get_movie_profile::{self, GetMovieProfileQuery},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_none_when_no_profile() {
|
async fn returns_none_when_no_profile() {
|
||||||
let ctx = TestContextBuilder::new().build();
|
let deps = GetMovieProfileDeps {
|
||||||
|
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||||
|
};
|
||||||
|
|
||||||
let result = get_movie_profile::execute(
|
let result = get_movie_profile::execute(
|
||||||
&ctx,
|
&deps,
|
||||||
GetMovieProfileQuery {
|
GetMovieProfileQuery {
|
||||||
movie_id: Uuid::new_v4(),
|
movie_id: Uuid::new_v4(),
|
||||||
},
|
},
|
||||||
@@ -69,12 +71,12 @@ async fn returns_profile_with_cast_and_crew() {
|
|||||||
};
|
};
|
||||||
profile_repo.upsert(&profile).await.unwrap();
|
profile_repo.upsert(&profile).await.unwrap();
|
||||||
|
|
||||||
let ctx = TestContextBuilder::new()
|
let deps = GetMovieProfileDeps {
|
||||||
.with_movie_profiles(Arc::clone(&profile_repo) as _)
|
movie_profile: Arc::clone(&profile_repo) as _,
|
||||||
.build();
|
};
|
||||||
|
|
||||||
let result = get_movie_profile::execute(
|
let result = get_movie_profile::execute(
|
||||||
&ctx,
|
&deps,
|
||||||
GetMovieProfileQuery {
|
GetMovieProfileQuery {
|
||||||
movie_id: movie_id.value(),
|
movie_id: movie_id.value(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use crate::{
|
use domain::testing::InMemoryMovieRepository;
|
||||||
movies::{get_movies, queries::GetMoviesQuery},
|
|
||||||
test_helpers::TestContextBuilder,
|
use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery};
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_when_no_movies() {
|
async fn returns_empty_when_no_movies() {
|
||||||
let ctx = TestContextBuilder::new().build();
|
let deps = GetMoviesDeps {
|
||||||
|
movie: InMemoryMovieRepository::new(),
|
||||||
|
};
|
||||||
|
|
||||||
let result = get_movies::execute(
|
let result = get_movies::execute(
|
||||||
&ctx,
|
&deps,
|
||||||
GetMoviesQuery {
|
GetMoviesQuery {
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
|
|||||||
@@ -6,20 +6,33 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::Movie,
|
models::Movie,
|
||||||
ports::{MetadataClient, MovieRepository},
|
ports::{MetadataClient, MovieRepository},
|
||||||
testing::InMemoryMovieRepository,
|
testing::{InMemoryMovieProfileRepository, InMemoryMovieRepository, NoopEventPublisher, NoopObjectStorage, FakeSearchCommand},
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
diary::commands::SyncPosterCommand, movies::sync_poster, test_helpers::TestContextBuilder,
|
diary::commands::SyncPosterCommand,
|
||||||
|
movies::{deps::SyncPosterDeps, sync_poster},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn default_deps() -> SyncPosterDeps {
|
||||||
|
SyncPosterDeps {
|
||||||
|
movie: InMemoryMovieRepository::new(),
|
||||||
|
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||||
|
metadata: Arc::new(domain::testing::FakeMetadataClient),
|
||||||
|
poster_fetcher: Arc::new(domain::testing::FakePosterFetcher),
|
||||||
|
object_storage: Arc::new(NoopObjectStorage),
|
||||||
|
event_publisher: NoopEventPublisher::new(),
|
||||||
|
search_command: Arc::new(FakeSearchCommand),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_movie_not_found() {
|
async fn fails_when_movie_not_found() {
|
||||||
let ctx = TestContextBuilder::new().build();
|
let deps = default_deps();
|
||||||
|
|
||||||
let result = sync_poster::execute(
|
let result = sync_poster::execute(
|
||||||
&ctx,
|
&deps,
|
||||||
SyncPosterCommand {
|
SyncPosterCommand {
|
||||||
movie_id: Uuid::new_v4(),
|
movie_id: Uuid::new_v4(),
|
||||||
},
|
},
|
||||||
@@ -42,11 +55,12 @@ async fn fails_when_no_external_id() {
|
|||||||
let movie_id = movie.id().value();
|
let movie_id = movie.id().value();
|
||||||
movies.upsert_movie(&movie).await.unwrap();
|
movies.upsert_movie(&movie).await.unwrap();
|
||||||
|
|
||||||
let ctx = TestContextBuilder::new()
|
let deps = SyncPosterDeps {
|
||||||
.with_movies(Arc::clone(&movies) as _)
|
movie: Arc::clone(&movies) as _,
|
||||||
.build();
|
..default_deps()
|
||||||
|
};
|
||||||
|
|
||||||
let result = sync_poster::execute(&ctx, SyncPosterCommand { movie_id }).await;
|
let result = sync_poster::execute(&deps, SyncPosterCommand { movie_id }).await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
@@ -85,12 +99,13 @@ async fn syncs_poster_for_movie_with_external_id() {
|
|||||||
let movie_id = movie.id().value();
|
let movie_id = movie.id().value();
|
||||||
movies.upsert_movie(&movie).await.unwrap();
|
movies.upsert_movie(&movie).await.unwrap();
|
||||||
|
|
||||||
let ctx = TestContextBuilder::new()
|
let deps = SyncPosterDeps {
|
||||||
.with_movies(Arc::clone(&movies) as _)
|
movie: Arc::clone(&movies) as _,
|
||||||
.with_metadata_client(Arc::new(FakeMetaWithPoster) as _)
|
metadata: Arc::new(FakeMetaWithPoster) as _,
|
||||||
.build();
|
..default_deps()
|
||||||
|
};
|
||||||
|
|
||||||
sync_poster::execute(&ctx, SyncPosterCommand { movie_id })
|
sync_poster::execute(&deps, SyncPosterCommand { movie_id })
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ use application::{
|
|||||||
get_movie_social_page, get_review_history,
|
get_movie_social_page, get_review_history,
|
||||||
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
|
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
|
||||||
},
|
},
|
||||||
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
|
movies::{
|
||||||
|
deps::{GetMovieProfileDeps, GetMoviesDeps, SyncPosterDeps},
|
||||||
|
get_movies,
|
||||||
|
queries::GetMoviesQuery,
|
||||||
|
sync_poster,
|
||||||
|
},
|
||||||
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
|
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
|
||||||
};
|
};
|
||||||
use domain::services::review_history::Trend;
|
use domain::services::review_history::Trend;
|
||||||
@@ -47,7 +52,9 @@ pub async fn list_movies(
|
|||||||
Query(params): Query<MoviesQueryParams>,
|
Query(params): Query<MoviesQueryParams>,
|
||||||
) -> Result<Json<MoviesResponse>, ApiError> {
|
) -> Result<Json<MoviesResponse>, ApiError> {
|
||||||
let page = get_movies::execute(
|
let page = get_movies::execute(
|
||||||
&state.app_ctx,
|
&GetMoviesDeps {
|
||||||
|
movie: state.app_ctx.repos.movie.clone(),
|
||||||
|
},
|
||||||
GetMoviesQuery {
|
GetMoviesQuery {
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
@@ -116,7 +123,19 @@ pub async fn sync_poster(
|
|||||||
_user: AuthenticatedUser,
|
_user: AuthenticatedUser,
|
||||||
Path(movie_id): Path<Uuid>,
|
Path(movie_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?;
|
sync_poster::execute(
|
||||||
|
&SyncPosterDeps {
|
||||||
|
movie: state.app_ctx.repos.movie.clone(),
|
||||||
|
movie_profile: state.app_ctx.repos.movie_profile.clone(),
|
||||||
|
metadata: state.app_ctx.services.metadata.clone(),
|
||||||
|
poster_fetcher: state.app_ctx.services.poster_fetcher.clone(),
|
||||||
|
object_storage: state.app_ctx.services.object_storage.clone(),
|
||||||
|
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||||
|
search_command: state.app_ctx.repos.search_command.clone(),
|
||||||
|
},
|
||||||
|
SyncPosterCommand { movie_id },
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +207,14 @@ pub async fn get_movie_profile(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
use application::movies::get_movie_profile;
|
use application::movies::get_movie_profile;
|
||||||
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
||||||
match get_movie_profile::execute(&state.app_ctx, query).await {
|
match get_movie_profile::execute(
|
||||||
|
&GetMovieProfileDeps {
|
||||||
|
movie_profile: state.app_ctx.repos.movie_profile.clone(),
|
||||||
|
},
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(result)) => {
|
Ok(Some(result)) => {
|
||||||
let p = result.profile;
|
let p = result.profile;
|
||||||
Json(MovieProfileResponse {
|
Json(MovieProfileResponse {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use application::{
|
|||||||
MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler,
|
MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler,
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
context::{AppContext, Repositories, Services},
|
context::{AppContext, Repositories, Services},
|
||||||
|
movies::deps::ReindexSearchDeps,
|
||||||
worker::WorkerService,
|
worker::WorkerService,
|
||||||
};
|
};
|
||||||
use export::ExportAdapter;
|
use export::ExportAdapter;
|
||||||
@@ -155,8 +156,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Some(person_enrichment_arc),
|
Some(person_enrichment_arc),
|
||||||
Arc::clone(&ctx.repos.person_command),
|
Arc::clone(&ctx.repos.person_command),
|
||||||
)) as Arc<dyn EventHandler>;
|
)) as Arc<dyn EventHandler>;
|
||||||
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(
|
||||||
as Arc<dyn PeriodicJob>;
|
Arc::clone(&ctx.repos.movie_profile),
|
||||||
|
Arc::clone(&ctx.services.event_publisher),
|
||||||
|
)) as Arc<dyn PeriodicJob>;
|
||||||
(Some(handler), Some(person_handler), Some(job))
|
(Some(handler), Some(person_handler), Some(job))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -234,7 +237,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
application::wrapup::event_handler::WrapUpEventHandler::new(ctx.clone()),
|
application::wrapup::event_handler::WrapUpEventHandler::new(ctx.clone()),
|
||||||
) as Arc<dyn EventHandler>;
|
) as Arc<dyn EventHandler>;
|
||||||
let reindex_handler =
|
let reindex_handler =
|
||||||
Arc::new(SearchReindexHandler::new(ctx.clone())) as Arc<dyn EventHandler>;
|
Arc::new(SearchReindexHandler::new(ReindexSearchDeps {
|
||||||
|
movie: Arc::clone(&ctx.repos.movie),
|
||||||
|
movie_profile: Arc::clone(&ctx.repos.movie_profile),
|
||||||
|
search_command: Arc::clone(&ctx.repos.search_command),
|
||||||
|
person_command: Arc::clone(&ctx.repos.person_command),
|
||||||
|
person_query: Arc::clone(&ctx.repos.person_query),
|
||||||
|
})) as Arc<dyn EventHandler>;
|
||||||
let mut h = vec![
|
let mut h = vec![
|
||||||
poster,
|
poster,
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -291,7 +300,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
application::wrapup::event_handler::WrapUpEventHandler::new(ctx.clone()),
|
application::wrapup::event_handler::WrapUpEventHandler::new(ctx.clone()),
|
||||||
) as Arc<dyn EventHandler>;
|
) as Arc<dyn EventHandler>;
|
||||||
let reindex_handler =
|
let reindex_handler =
|
||||||
Arc::new(SearchReindexHandler::new(ctx.clone())) as Arc<dyn EventHandler>;
|
Arc::new(SearchReindexHandler::new(ReindexSearchDeps {
|
||||||
|
movie: Arc::clone(&ctx.repos.movie),
|
||||||
|
movie_profile: Arc::clone(&ctx.repos.movie_profile),
|
||||||
|
search_command: Arc::clone(&ctx.repos.search_command),
|
||||||
|
person_command: Arc::clone(&ctx.repos.person_command),
|
||||||
|
person_query: Arc::clone(&ctx.repos.person_query),
|
||||||
|
})) as Arc<dyn EventHandler>;
|
||||||
let mut h = vec![
|
let mut h = vec![
|
||||||
poster,
|
poster,
|
||||||
cleanup,
|
cleanup,
|
||||||
|
|||||||
Reference in New Issue
Block a user