feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
|
||||
56
crates/application/src/use_cases/add_to_watchlist.rs
Normal file
56
crates/application/src/use_cases/add_to_watchlist.rs
Normal 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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
crates/application/src/use_cases/get_watchlist.rs
Normal file
16
crates/application/src/use_cases/get_watchlist.rs
Normal 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
|
||||
}
|
||||
12
crates/application/src/use_cases/is_on_watchlist.rs
Normal file
12
crates/application/src/use_cases/is_on_watchlist.rs
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
crates/application/src/use_cases/remove_from_watchlist.rs
Normal file
20
crates/application/src/use_cases/remove_from_watchlist.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user