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

@@ -2,14 +2,17 @@ use chrono::NaiveDateTime;
use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid;
pub struct LogReviewCommand {
pub struct MovieInput {
pub movie_id: Option<Uuid>,
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
}
pub struct LogReviewCommand {
pub user_id: Uuid,
pub input: MovieInput,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
@@ -81,3 +84,13 @@ pub struct EnrichMovieCommand {
pub movie_id: domain::value_objects::MovieId,
pub profile: domain::models::MovieProfile,
}
pub struct AddToWatchlistCommand {
pub user_id: Uuid,
pub input: MovieInput,
}
pub struct RemoveFromWatchlistCommand {
pub user_id: Uuid,
pub movie_id: Uuid,
}

View File

@@ -7,7 +7,10 @@ use domain::ports::{
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
PersonCommand, PersonQuery, SearchCommand, SearchPort,
ReviewRepository, StatsRepository, UserRepository,
WatchlistRepository,
};
#[cfg(feature = "federation")]
use domain::ports::RemoteWatchlistRepository;
use crate::config::AppConfig;
@@ -33,5 +36,8 @@ pub struct AppContext {
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub watchlist_repository: Arc<dyn WatchlistRepository>,
#[cfg(feature = "federation")]
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
pub config: AppConfig,
}

View File

@@ -6,7 +6,7 @@ use domain::{
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
};
use crate::commands::LogReviewCommand;
use crate::commands::MovieInput;
pub struct MovieResolverDeps<'a> {
pub repository: &'a dyn MovieRepository,
@@ -15,10 +15,10 @@ pub struct MovieResolverDeps<'a> {
#[async_trait]
pub trait ResolutionStrategy: Send + Sync {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool;
fn can_handle(&self, input: &MovieInput) -> bool;
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError>;
}
@@ -44,15 +44,14 @@ impl MovieResolver {
pub async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies {
if strategy.can_handle(cmd) {
if let Some(result) = strategy.resolve(cmd, deps).await? {
if strategy.can_handle(input)
&& let Some(result) = strategy.resolve(input, deps).await? {
return Ok(result);
}
}
}
Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(),
@@ -62,16 +61,16 @@ impl MovieResolver {
#[async_trait]
impl ResolutionStrategy for ExternalIdStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.external_metadata_id.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.external_metadata_id.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let ext_id_str = cmd.external_metadata_id.as_deref().unwrap();
let ext_id_str = input.external_metadata_id.as_deref().unwrap();
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
@@ -97,22 +96,30 @@ impl ResolutionStrategy for ExternalIdStrategy {
#[async_trait]
impl ResolutionStrategy for TitleSearchStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title = cmd.manual_title.as_deref().unwrap();
let title = input.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title {
title: MovieTitle::new(title.to_string())?,
year: cmd.manual_release_year.map(ReleaseYear::new).transpose()?,
year: input.manual_release_year.map(ReleaseYear::new).transpose()?,
};
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => Ok(Some((m, true))),
Ok(m) => {
// Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id() {
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? {
return Ok(Some((existing, false)));
}
}
Ok(Some((m, true)))
}
Err(e) => {
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
Ok(None)
@@ -123,20 +130,20 @@ impl ResolutionStrategy for TitleSearchStrategy {
#[async_trait]
impl ResolutionStrategy for ManualMovieStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title_str = match &cmd.manual_title {
let title_str = match &input.manual_title {
Some(t) => t,
None => return Ok(None),
};
let year_val = cmd.manual_release_year.ok_or_else(|| {
let year_val = input.manual_release_year.ok_or_else(|| {
DomainError::ValidationError(
"Manual release year required if TMDB fetch fails or is omitted".into(),
)
@@ -152,13 +159,13 @@ impl ResolutionStrategy for ManualMovieStrategy {
let matched = candidates
.into_iter()
.find(|m| m.is_manual_match(&title, &release_year, cmd.manual_director.as_deref()));
.find(|m| m.is_manual_match(&title, &release_year, input.manual_director.as_deref()));
if let Some(existing) = matched {
Ok(Some((existing, false)))
} else {
let new_movie =
Movie::new(None, title, release_year, cmd.manual_director.clone(), None);
Movie::new(None, title, release_year, input.manual_director.clone(), None);
Ok(Some((new_movie, true)))
}
}

View File

@@ -1,8 +1,8 @@
use uuid::Uuid;
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, Movie, MovieStats, UserStats, UserSummary, UserTrends,
collections::Paginated,
DiaryEntry, FeedEntry, MonthActivity, Movie, MovieProfile, MovieStats, UserStats, UserSummary,
UserTrends, collections::Paginated,
};
pub struct RemoteActorView {
@@ -102,12 +102,38 @@ pub struct MovieDetailPageData {
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub profile: Option<MovieProfile>,
pub on_watchlist: bool,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub histogram_max: u64,
}
#[derive(Clone, Debug)]
pub struct WatchlistDisplayEntry {
/// Always a full URL: /images/{path} for local, https://... for remote
pub poster_url: Option<String>,
pub movie_title: String,
pub release_year: u16,
/// /movies/{id} for local; None for remote entries without a local movie record
pub movie_url: Option<String>,
pub added_at: String,
/// /watchlist/{movie_id}/remove for owner; None for remote or non-owner
pub remove_url: Option<String>,
}
pub struct WatchlistPageData {
pub ctx: HtmlPageContext,
pub owner_id: uuid::Uuid,
pub display_entries: Vec<WatchlistDisplayEntry>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub is_owner: bool,
pub error: Option<String>,
}
pub struct ImportUploadPageData {
pub ctx: HtmlPageContext,
pub profiles: Vec<ImportProfileView>,
@@ -201,6 +227,7 @@ pub trait HtmlRenderer: Send + Sync {
) -> Result<String, String>;
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync {

View File

@@ -85,4 +85,17 @@ pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
pub genre: Option<String>,
pub language: Option<String>,
}
pub struct GetWatchlistQuery {
pub user_id: Uuid,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub struct IsOnWatchlistQuery {
pub user_id: Uuid,
pub movie_id: Uuid,
}

View File

@@ -1,5 +1,5 @@
use super::*;
use chrono::NaiveDate;
use crate::commands::MovieInput;
use domain::{
errors::DomainError,
models::Movie,
@@ -7,19 +7,13 @@ use domain::{
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
};
fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> LogReviewCommand {
LogReviewCommand {
fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> MovieInput {
MovieInput {
movie_id: None,
external_metadata_id: ext_id.map(String::from),
manual_title: title.map(String::from),
manual_release_year: year,
manual_director: None,
user_id: uuid::Uuid::new_v4(),
rating: 4,
comment: None,
watched_at: NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
}
}
@@ -59,7 +53,7 @@ impl MovieRepository for RepoWithExternalMovie {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
#[async_trait::async_trait]
@@ -82,7 +76,7 @@ impl MovieRepository for RepoEmpty {
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
#[async_trait::async_trait]
@@ -105,7 +99,7 @@ impl MovieRepository for RepoWithTitleMatch {
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
struct MetaReturnsMovie(Movie);
@@ -149,14 +143,14 @@ impl MetadataClient for MetaErrors {
#[test]
fn external_id_strategy_can_handle_cmd_with_id() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&input));
}
#[test]
fn external_id_strategy_cannot_handle_cmd_without_id() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&input));
}
#[tokio::test]
@@ -168,8 +162,8 @@ async fn external_id_strategy_returns_cached_movie() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
@@ -182,8 +176,8 @@ async fn external_id_strategy_fetches_from_metadata_when_not_cached() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -195,8 +189,8 @@ async fn external_id_strategy_falls_through_on_metadata_error() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
@@ -204,14 +198,14 @@ async fn external_id_strategy_falls_through_on_metadata_error() {
#[test]
fn title_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&input));
}
#[test]
fn title_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&input));
}
#[tokio::test]
@@ -223,8 +217,8 @@ async fn title_strategy_fetches_from_metadata() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -236,8 +230,8 @@ async fn title_strategy_falls_through_on_metadata_error() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
@@ -245,14 +239,14 @@ async fn title_strategy_falls_through_on_metadata_error() {
#[test]
fn manual_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&input));
}
#[test]
fn manual_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&input));
}
#[tokio::test]
@@ -264,8 +258,8 @@ async fn manual_strategy_returns_existing_movie() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
@@ -277,8 +271,8 @@ async fn manual_strategy_creates_new_movie_when_no_match() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -290,8 +284,8 @@ async fn manual_strategy_errors_without_year() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&cmd, &deps).await.is_err());
let input = make_input(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&input, &deps).await.is_err());
}
// --- MovieResolver pipeline ---
@@ -304,8 +298,8 @@ async fn resolver_returns_error_when_no_strategy_matches() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&cmd, &deps).await;
let input = make_input(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&input, &deps).await;
assert!(result.is_err());
}
@@ -318,9 +312,9 @@ async fn resolver_uses_cached_movie_when_external_id_matches() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let input = make_input(Some("tt123"), None, None);
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.resolve(&input, &deps)
.await
.unwrap();
assert!(!is_new);
@@ -334,9 +328,9 @@ async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), Some("Inception"), Some(2010));
let input = make_input(Some("tt123"), Some("Inception"), Some(2010));
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.resolve(&input, &deps)
.await
.unwrap();
assert!(is_new);

View File

@@ -45,6 +45,7 @@ impl EventHandler for RecordingHandler {
DomainEvent::UserUpdated { .. } => "user_updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested",
DomainEvent::ImageStored { .. } => "image_stored",
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => "watchlist",
};
self.calls.lock().unwrap().push(label);
Ok(())

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(())
}