feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -44,6 +44,18 @@ pub enum DomainEvent {
|
||||
ImageStored {
|
||||
key: String,
|
||||
},
|
||||
WatchlistEntryAdded {
|
||||
user_id: UserId,
|
||||
movie_id: MovieId,
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<String>,
|
||||
added_at: chrono::NaiveDateTime,
|
||||
},
|
||||
WatchlistEntryRemoved {
|
||||
user_id: UserId,
|
||||
movie_id: MovieId,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -14,6 +14,10 @@ pub mod import_session;
|
||||
pub mod import_profile;
|
||||
pub mod person;
|
||||
pub mod search;
|
||||
pub mod watchlist;
|
||||
pub use watchlist::{WatchlistEntry, WatchlistWithMovie};
|
||||
pub mod remote_watchlist;
|
||||
pub use remote_watchlist::RemoteWatchlistEntry;
|
||||
|
||||
pub use import::{
|
||||
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError,
|
||||
@@ -45,6 +49,23 @@ pub struct DiaryFilter {
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MovieFilter {
|
||||
pub search: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MovieSummary {
|
||||
pub movie: Movie,
|
||||
pub genres: Vec<String>,
|
||||
pub runtime_minutes: Option<u32>,
|
||||
pub original_language: Option<String>,
|
||||
pub overview: Option<String>,
|
||||
pub collection_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Movie {
|
||||
id: MovieId,
|
||||
@@ -133,18 +154,13 @@ impl Movie {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ReviewSource {
|
||||
#[default]
|
||||
Local,
|
||||
Remote { actor_url: String },
|
||||
}
|
||||
|
||||
impl Default for ReviewSource {
|
||||
fn default() -> Self {
|
||||
ReviewSource::Local
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Review {
|
||||
id: ReviewId,
|
||||
|
||||
12
crates/domain/src/models/remote_watchlist.rs
Normal file
12
crates/domain/src/models/remote_watchlist.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteWatchlistEntry {
|
||||
pub ap_id: String,
|
||||
pub actor_url: String,
|
||||
pub movie_title: String,
|
||||
pub release_year: u16,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub poster_url: Option<String>,
|
||||
pub added_at: DateTime<Utc>,
|
||||
}
|
||||
31
crates/domain/src/models/watchlist.rs
Normal file
31
crates/domain/src/models/watchlist.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
models::Movie,
|
||||
value_objects::{MovieId, UserId, WatchlistEntryId},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WatchlistEntry {
|
||||
pub id: WatchlistEntryId,
|
||||
pub user_id: UserId,
|
||||
pub movie_id: MovieId,
|
||||
pub added_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl WatchlistEntry {
|
||||
pub fn new(user_id: UserId, movie_id: MovieId) -> Self {
|
||||
Self {
|
||||
id: WatchlistEntryId::generate(),
|
||||
user_id,
|
||||
movie_id,
|
||||
added_at: Utc::now().naive_utc(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WatchlistWithMovie {
|
||||
pub entry: WatchlistEntry,
|
||||
pub movie: Movie,
|
||||
}
|
||||
@@ -6,10 +6,10 @@ use crate::{
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
models::{
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
|
||||
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
|
||||
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
|
||||
EntityType, ExternalPersonId, IndexableDocument, Person, PersonCredits,
|
||||
PersonId, SearchQuery, SearchResults,
|
||||
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
|
||||
MovieStats, MovieSummary, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary,
|
||||
UserTrends, WatchlistEntry, WatchlistWithMovie, RemoteWatchlistEntry, EntityType, ExternalPersonId,
|
||||
IndexableDocument, Person, PersonCredits, PersonId, SearchQuery, SearchResults,
|
||||
collections::{self, PageParams, Paginated},
|
||||
},
|
||||
value_objects::{
|
||||
@@ -88,8 +88,8 @@ pub trait MovieRepository: Send + Sync {
|
||||
async fn list_movies(
|
||||
&self,
|
||||
page: &collections::PageParams,
|
||||
search: Option<&str>,
|
||||
) -> Result<collections::Paginated<Movie>, DomainError>;
|
||||
filter: &MovieFilter,
|
||||
) -> Result<collections::Paginated<MovieSummary>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -310,3 +310,41 @@ pub trait SearchCommand: Send + Sync {
|
||||
/// Remove a document from the search index by entity type and internal ID string.
|
||||
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WatchlistRepository: Send + Sync {
|
||||
/// Add a new entry. Silently succeeds if the entry already exists.
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove an entry. Returns NotFound if the entry does not exist.
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove an entry if it exists. Never returns NotFound.
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError>;
|
||||
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &collections::PageParams,
|
||||
) -> Result<collections::Paginated<WatchlistWithMovie>, DomainError>;
|
||||
|
||||
async fn contains(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RemoteWatchlistRepository: Send + Sync {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>;
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
|
||||
/// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID.
|
||||
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ uuid_id!(ReviewId);
|
||||
uuid_id!(UserId);
|
||||
uuid_id!(ImportSessionId);
|
||||
uuid_id!(ImportProfileId);
|
||||
uuid_id!(WatchlistEntryId);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExternalMetadataId(String);
|
||||
@@ -80,7 +81,7 @@ impl MovieTitle {
|
||||
))
|
||||
} else if trimmed.len() > Self::MAX_LENGTH {
|
||||
Err(DomainError::ValidationError(
|
||||
format!("Movie title exceeds {} characters", Self::MAX_LENGTH).into(),
|
||||
format!("Movie title exceeds {} characters", Self::MAX_LENGTH),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
@@ -102,7 +103,7 @@ impl Comment {
|
||||
let trimmed = comment.trim();
|
||||
if trimmed.len() > Self::MAX_LENGTH {
|
||||
Err(DomainError::ValidationError(
|
||||
format!("Comment exceeds {} characters", Self::MAX_LENGTH).into(),
|
||||
format!("Comment exceeds {} characters", Self::MAX_LENGTH),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(trimmed.to_string()))
|
||||
@@ -190,8 +191,7 @@ impl Username {
|
||||
"Username must be {}–{} characters",
|
||||
Self::MIN_LENGTH,
|
||||
Self::MAX_LENGTH
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
));
|
||||
}
|
||||
if !s
|
||||
|
||||
Reference in New Issue
Block a user