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

@@ -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]

View File

@@ -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,

View 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>,
}

View 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,
}

View File

@@ -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>;
}

View File

@@ -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