use chrono::{NaiveDateTime, Utc}; use crate::{ errors::DomainError, models::collections::PageParams, value_objects::{ Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, Rating, ReleaseYear, ReviewId, UserId, Username, }, }; pub mod collections; pub mod import; pub mod import_session; pub mod import_profile; pub use import::{ AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError, ImportRow, ParsedFile, RowResult, Transform, }; pub use import_session::ImportSession; pub use import_profile::ImportProfile; #[derive(Clone, Debug, Default)] pub enum SortDirection { #[default] Descending, Ascending, ByRatingDesc, ByRatingAsc, } #[derive(Clone, Debug, Default)] pub struct DiaryFilter { pub sort_by: SortDirection, pub page: PageParams, pub movie_id: Option, pub user_id: Option, pub search: Option, } #[derive(Clone, Debug)] pub struct Movie { id: MovieId, external_metadata_id: Option, title: MovieTitle, release_year: ReleaseYear, director: Option, poster_path: Option, } impl Movie { pub fn new( external_metadata_id: Option, title: MovieTitle, release_year: ReleaseYear, director: Option, poster_path: Option, ) -> Self { Self { id: MovieId::generate(), external_metadata_id, title, release_year, director, poster_path, } } pub fn from_persistence( id: MovieId, external_metadata_id: Option, title: MovieTitle, release_year: ReleaseYear, director: Option, poster_path: Option, ) -> Self { Self { id, external_metadata_id, title, release_year, director, poster_path, } } 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) -> Option<&ExternalMetadataId> { self.external_metadata_id.as_ref() } pub fn title(&self) -> &MovieTitle { &self.title } pub fn release_year(&self) -> &ReleaseYear { &self.release_year } pub fn director(&self) -> Option<&str> { self.director.as_deref() } pub fn poster_path(&self) -> Option<&PosterPath> { self.poster_path.as_ref() } } 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, PartialEq, Eq)] pub enum ReviewSource { Local, Remote { actor_url: String }, } impl Default for ReviewSource { fn default() -> Self { ReviewSource::Local } } #[derive(Clone, Debug)] pub struct Review { id: ReviewId, movie_id: MovieId, user_id: UserId, rating: Rating, comment: Option, watched_at: chrono::NaiveDateTime, created_at: chrono::NaiveDateTime, source: ReviewSource, } impl Review { pub fn new( movie_id: MovieId, user_id: UserId, rating: Rating, comment: Option, watched_at: NaiveDateTime, ) -> Result { Ok(Self { id: ReviewId::generate(), movie_id, user_id, rating, comment, watched_at, created_at: Utc::now().naive_utc(), source: ReviewSource::Local, }) } pub fn from_persistence( id: ReviewId, movie_id: MovieId, user_id: UserId, rating: Rating, comment: Option, watched_at: NaiveDateTime, created_at: NaiveDateTime, source: ReviewSource, ) -> Self { Self { id, movie_id, user_id, rating, comment, watched_at, created_at, source, } } pub fn id(&self) -> &ReviewId { &self.id } pub fn movie_id(&self) -> &MovieId { &self.movie_id } pub fn user_id(&self) -> &UserId { &self.user_id } pub fn rating(&self) -> &Rating { &self.rating } pub fn comment(&self) -> Option<&Comment> { self.comment.as_ref() } pub fn watched_at(&self) -> &NaiveDateTime { &self.watched_at } pub fn created_at(&self) -> &NaiveDateTime { &self.created_at } pub fn source(&self) -> &ReviewSource { &self.source } /// Returns [star1_filled, star2_filled, ..., star5_filled] pub fn stars(&self) -> [bool; 5] { let r = self.rating.value(); [r >= 1, r >= 2, r >= 3, r >= 4, r >= 5] } pub fn is_remote(&self) -> bool { matches!(self.source, ReviewSource::Remote { .. }) } } #[derive(Clone, Debug)] pub struct DiaryEntry { 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 { movie: Movie, viewings: Vec, } impl ReviewHistory { pub fn new(movie: Movie, viewings: Vec) -> Self { Self { movie, viewings } } pub fn movie(&self) -> &Movie { &self.movie } pub fn viewings(&self) -> &[Review] { &self.viewings } pub fn sort_by_date(&mut self) { self.viewings.sort_by_key(|r| *r.watched_at()); } } #[derive(Clone, Debug)] pub struct MovieStats { pub total_count: u64, pub avg_rating: Option, pub federated_count: u64, pub rating_histogram: [u64; 5], // index 0 = 1★, index 4 = 5★ } #[derive(Clone, Debug, Default)] pub enum UserRole { #[default] Standard, Admin, } #[derive(Clone, Debug)] pub struct User { id: UserId, email: Email, username: Username, password_hash: PasswordHash, role: UserRole, bio: Option, avatar_path: Option, } impl User { pub fn new( email: Email, username: Username, password_hash: PasswordHash, role: UserRole, ) -> Self { Self { id: UserId::generate(), email, username, password_hash, role, bio: None, avatar_path: None, } } pub fn from_persistence( id: UserId, email: Email, username: Username, password_hash: PasswordHash, role: UserRole, bio: Option, avatar_path: Option, ) -> Self { Self { id, email, username, password_hash, role, bio, avatar_path, } } pub fn update_password(&mut self, new_hash: PasswordHash) { self.password_hash = new_hash; } pub fn update_profile(&mut self, bio: Option, avatar_path: Option) { self.bio = bio; self.avatar_path = avatar_path; } pub fn email(&self) -> &Email { &self.email } pub fn username(&self) -> &Username { &self.username } pub fn id(&self) -> &UserId { &self.id } pub fn password_hash(&self) -> &PasswordHash { &self.password_hash } pub fn role(&self) -> &UserRole { &self.role } pub fn bio(&self) -> Option<&str> { self.bio.as_deref() } pub fn avatar_path(&self) -> Option<&str> { self.avatar_path.as_deref() } } #[derive(Clone, Debug)] pub struct FeedEntry { entry: DiaryEntry, user_email: String, } impl FeedEntry { pub fn new(entry: DiaryEntry, user_email: String) -> Self { Self { entry, user_email } } pub fn movie(&self) -> &Movie { self.entry.movie() } pub fn review(&self) -> &Review { self.entry.review() } pub fn user_email(&self) -> &str { &self.user_email } pub fn user_display_name(&self) -> &str { self.user_email .split('@') .next() .unwrap_or(&self.user_email) } } #[derive(Clone, Debug)] pub struct UserSummary { pub user_id: UserId, email: Email, pub total_movies: i64, pub avg_rating: Option, } impl UserSummary { pub fn new(user_id: UserId, email: Email, total_movies: i64, avg_rating: Option) -> Self { Self { user_id, email, total_movies, avg_rating, } } pub fn email(&self) -> &str { self.email.value() } } #[derive(Clone, Debug)] pub struct UserStats { pub total_movies: i64, pub avg_rating: Option, pub favorite_director: Option, pub most_active_month: Option, } #[derive(Clone, Debug)] pub struct MonthActivity { pub year_month: String, pub month_label: String, pub count: i64, pub entries: Vec, } #[derive(Clone, Debug)] pub struct MonthlyRating { pub year_month: String, pub month_label: String, pub avg_rating: f64, pub count: i64, } #[derive(Clone, Debug)] pub struct DirectorStat { pub director: String, pub count: i64, } #[derive(Clone, Debug)] pub struct UserTrends { pub monthly_ratings: Vec, pub top_directors: Vec, pub max_director_count: i64, } pub enum ExportFormat { Csv, Json, } #[cfg(test)] mod tests { use super::*; use crate::value_objects::{Email, PasswordHash, UserId, Username}; fn make_user() -> User { User::from_persistence( UserId::generate(), Email::new("a@b.com".to_string()).unwrap(), Username::new("alice".to_string()).unwrap(), PasswordHash::new("hash".to_string()).unwrap(), UserRole::Standard, None, None, ) } #[test] fn update_profile_sets_fields() { let mut user = make_user(); user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string())); assert_eq!(user.bio(), Some("My bio")); assert_eq!(user.avatar_path(), Some("avatars/abc")); } #[test] fn update_profile_clears_with_none() { let mut user = make_user(); user.update_profile(Some("bio".to_string()), Some("path".to_string())); user.update_profile(None, None); assert_eq!(user.bio(), None); assert_eq!(user.avatar_path(), None); } }