@@ -1,6 +1,6 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::value_objects::{MovieId, Rating, ReviewId, UserId};
|
||||
use crate::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DomainEvent {
|
||||
@@ -11,4 +11,8 @@ pub enum DomainEvent {
|
||||
rating: Rating,
|
||||
watched_at: NaiveDateTime,
|
||||
},
|
||||
MovieDiscovered {
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub struct DiaryFilter {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Movie {
|
||||
id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
external_metadata_id: Option<ExternalMetadataId>,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
@@ -36,7 +36,7 @@ pub struct Movie {
|
||||
|
||||
impl Movie {
|
||||
pub fn new(
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
external_metadata_id: Option<ExternalMetadataId>,
|
||||
title: MovieTitle,
|
||||
release_year: ReleaseYear,
|
||||
director: Option<String>,
|
||||
@@ -52,11 +52,15 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_poster(&mut self, poster_path: PosterPath) {
|
||||
self.poster_path = Some(poster_path);
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &MovieId {
|
||||
&self.id
|
||||
}
|
||||
pub fn external_metadata_id(&self) -> &ExternalMetadataId {
|
||||
&self.external_metadata_id
|
||||
pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> {
|
||||
self.external_metadata_id.as_ref()
|
||||
}
|
||||
pub fn title(&self) -> &MovieTitle {
|
||||
&self.title
|
||||
@@ -72,6 +76,24 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
impl Movie {
|
||||
pub fn is_manual_match(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
director: Option<&str>,
|
||||
) -> bool {
|
||||
if self.title != *title || self.release_year != *year {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (self.director(), director) {
|
||||
(Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Review {
|
||||
id: ReviewId,
|
||||
@@ -135,14 +157,43 @@ impl Review {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiaryEntry {
|
||||
pub movie: Movie,
|
||||
pub review: Review,
|
||||
movie: Movie,
|
||||
review: Review,
|
||||
}
|
||||
|
||||
impl DiaryEntry {
|
||||
pub fn new(movie: Movie, review: Review) -> Self {
|
||||
Self { movie, review }
|
||||
}
|
||||
|
||||
pub fn movie(&self) -> &Movie {
|
||||
&self.movie
|
||||
}
|
||||
pub fn review(&self) -> &Review {
|
||||
&self.review
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReviewHistory {
|
||||
pub movie: Movie,
|
||||
pub viewings: Vec<Review>,
|
||||
movie: Movie,
|
||||
viewings: Vec<Review>,
|
||||
}
|
||||
|
||||
impl ReviewHistory {
|
||||
pub fn new(movie: Movie, viewings: Vec<Review>) -> Self {
|
||||
Self { movie, viewings }
|
||||
}
|
||||
|
||||
pub fn movie(&self) -> &Movie {
|
||||
&self.movie
|
||||
}
|
||||
pub fn viewings(&self) -> &[Review] {
|
||||
&self.viewings
|
||||
}
|
||||
pub fn viewings_mut(&mut self) -> &mut Vec<Review> {
|
||||
&mut self.viewings
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -4,11 +4,25 @@ use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
|
||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, UserId},
|
||||
value_objects::{
|
||||
ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, ReleaseYear,
|
||||
UserId,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait MovieRepository: Send + Sync {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError>;
|
||||
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
|
||||
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
|
||||
@@ -25,11 +39,15 @@ pub trait MetadataClient: Send + Sync {
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Movie, DomainError>;
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PosterFetcherClient: Send + Sync {
|
||||
async fn fetch_poster_bytes(&self, poster_url: &str) -> Result<Vec<u8>, DomainError>;
|
||||
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -12,35 +12,35 @@ pub enum Trend {
|
||||
impl ReviewHistoryAnalyzer {
|
||||
pub fn sort_chronologically(history: &mut ReviewHistory) {
|
||||
history
|
||||
.viewings
|
||||
.viewings_mut()
|
||||
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
|
||||
}
|
||||
|
||||
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
|
||||
history
|
||||
.viewings
|
||||
.viewings()
|
||||
.iter()
|
||||
.max_by_key(|r| r.watched_at())
|
||||
.map(|r| r.rating())
|
||||
}
|
||||
|
||||
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
|
||||
if history.viewings.len() < 2 {
|
||||
if history.viewings().len() < 2 {
|
||||
return Ok(Trend::Neutral);
|
||||
}
|
||||
|
||||
let mut sorted_history = history.clone();
|
||||
Self::sort_chronologically(&mut sorted_history);
|
||||
|
||||
let latest_review = sorted_history.viewings.pop().unwrap();
|
||||
let latest_review = sorted_history.viewings().last().unwrap();
|
||||
let latest_rating = latest_review.rating().value() as f32;
|
||||
|
||||
let previous_sum: u32 = sorted_history
|
||||
.viewings
|
||||
.viewings()
|
||||
.iter()
|
||||
.map(|r| r.rating().value() as u32)
|
||||
.sum();
|
||||
let historical_average = previous_sum as f32 / sorted_history.viewings.len() as f32;
|
||||
let historical_average = previous_sum as f32 / sorted_history.viewings().len() as f32;
|
||||
|
||||
if latest_rating > historical_average {
|
||||
Ok(Trend::Improved)
|
||||
|
||||
@@ -206,3 +206,23 @@ impl PasswordHash {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PosterUrl(String);
|
||||
|
||||
impl PosterUrl {
|
||||
pub fn new(url: String) -> Result<Self, DomainError> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
Err(DomainError::ValidationError(
|
||||
"Poster URL cannot be empty".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user