feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation

This commit is contained in:
2026-05-13 00:23:45 +02:00
parent 2fd8734d23
commit 53df90ab1f
84 changed files with 2755 additions and 398 deletions

View File

@@ -0,0 +1,56 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::WatchlistEntry,
value_objects::{MovieId, UserId},
};
use crate::{
commands::AddToWatchlistCommand,
context::AppContext,
movie_resolver::{MovieResolver, MovieResolverDeps},
};
pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
ctx.movie_repository
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
} else {
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
let (movie, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?;
if is_new {
ctx.movie_repository.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = ctx.event_publisher.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
}).await;
}
}
movie
};
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
ctx.watchlist_repository.add(&entry).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
}).await;
Ok(())
}

View File

@@ -36,13 +36,11 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result
}
async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result<bool, DomainError> {
if let Some(ext_id) = &row.external_metadata_id {
if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) {
if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {
if let Some(ext_id) = &row.external_metadata_id
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
&& ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {
return Ok(true);
}
}
}
if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) {
let title_vo = MovieTitle::new(title.clone());
let year_vo = year_str.parse::<u16>().ok().and_then(|y| ReleaseYear::new(y).ok());

View File

@@ -6,7 +6,7 @@ use domain::{
};
use uuid::Uuid;
use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review};
use crate::{commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, use_cases::log_review};
pub struct ImportSummary {
pub imported: usize,
@@ -71,11 +71,14 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
Ok(LogReviewCommand {
external_metadata_id: row.external_metadata_id.clone(),
manual_title: row.title.clone(),
manual_release_year: row.release_year.as_deref().and_then(|s| s.parse().ok()),
manual_director: row.director.clone(),
user_id,
input: MovieInput {
movie_id: None,
external_metadata_id: row.external_metadata_id.clone(),
manual_title: row.title.clone(),
manual_release_year: row.release_year.as_deref().and_then(|s| s.parse().ok()),
manual_director: row.director.clone(),
},
rating,
comment: row.comment.clone(),
watched_at,

View File

@@ -1,6 +1,6 @@
use domain::{
errors::DomainError,
models::{FeedEntry, Movie, MovieStats, collections::{PageParams, Paginated}},
models::{FeedEntry, Movie, MovieProfile, MovieStats, collections::{PageParams, Paginated}},
value_objects::MovieId,
};
@@ -10,6 +10,7 @@ pub struct MovieSocialPageResult {
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub profile: Option<MovieProfile>,
}
pub async fn execute(
@@ -25,10 +26,11 @@ pub async fn execute(
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
let (stats, reviews) = tokio::try_join!(
let (stats, reviews, profile) = tokio::try_join!(
ctx.diary_repository.get_movie_stats(&movie_id),
ctx.diary_repository.get_movie_social_feed(&movie_id, &page),
ctx.movie_profile_repository.get_by_movie_id(&movie_id),
)?;
Ok(MovieSocialPageResult { movie, stats, reviews })
Ok(MovieSocialPageResult { movie, stats, reviews, profile })
}

View File

@@ -1,14 +1,17 @@
use domain::{
errors::DomainError,
models::collections::{PageParams, Paginated},
models::Movie,
models::{MovieFilter, MovieSummary},
};
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<Movie>, DomainError> {
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<MovieSummary>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
ctx.movie_repository
.list_movies(&page, query.search.as_deref())
.await
let filter = MovieFilter {
search: query.search,
genre: query.genre,
language: query.language,
};
ctx.movie_repository.list_movies(&page, &filter).await
}

View File

@@ -0,0 +1,16 @@
use domain::{
errors::DomainError,
models::{WatchlistWithMovie, collections::{PageParams, Paginated}},
value_objects::UserId,
};
use crate::{context::AppContext, queries::GetWatchlistQuery};
pub async fn execute(
ctx: &AppContext,
query: GetWatchlistQuery,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let page = PageParams::new(query.limit, query.offset)?;
ctx.watchlist_repository.get_for_user(&user_id, &page).await
}

View File

@@ -0,0 +1,12 @@
use domain::{
errors::DomainError,
value_objects::{MovieId, UserId},
};
use crate::{context::AppContext, queries::IsOnWatchlistQuery};
pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let movie_id = MovieId::from_uuid(query.movie_id);
ctx.watchlist_repository.contains(&user_id, &movie_id).await
}

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
value_objects::{Comment, Rating, UserId},
value_objects::{Comment, MovieId, Rating, UserId},
};
use crate::{
@@ -16,19 +16,39 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
let movie = ctx
.movie_repository
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
(movie, false)
} else {
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?
};
let (movie, is_new_movie) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await?;
ctx.movie_repository.upsert_movie(&movie).await?;
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review_event = ctx.review_repository.save_review(&review).await?;
let was_on_watchlist = ctx.watchlist_repository
.remove_if_present(review.user_id(), review.movie_id())
.await?;
if was_on_watchlist {
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
}).await;
}
publish_events(ctx, &movie, is_new_movie, review_event).await?;
Ok(())
@@ -40,15 +60,14 @@ async fn publish_events(
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie {
if let Some(ext_id) = movie.external_metadata_id() {
if is_new_movie
&& let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.event_publisher.publish(&discovery_event).await?;
}
}
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {

View File

@@ -24,3 +24,7 @@ pub mod register;
pub mod search;
pub mod sync_poster;
pub mod update_profile;
pub mod add_to_watchlist;
pub mod remove_from_watchlist;
pub mod get_watchlist;
pub mod is_on_watchlist;

View File

@@ -0,0 +1,20 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{MovieId, UserId},
};
use crate::{commands::RemoveFromWatchlistCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie_id = MovieId::from_uuid(cmd.movie_id);
ctx.watchlist_repository.remove(&user_id, &movie_id).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id,
movie_id,
}).await;
Ok(())
}