diff --git a/.gitignore b/.gitignore index 073e5f5..a3e75b5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ .worktrees/ .superpowers/ docs/ + +imgs/ \ No newline at end of file diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index 206b642..2737709 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -200,7 +200,7 @@ pub struct UndoActivity { #[serde(rename = "type", default)] pub(crate) kind: UndoType, pub(crate) actor: ObjectId, - pub(crate) object: FollowActivity, + pub(crate) object: serde_json::Value, } #[async_trait::async_trait] @@ -223,19 +223,51 @@ impl Activity for UndoActivity { async fn receive(self, data: &Data) -> Result<(), Self::Error> { let domain = self.actor().host_str().unwrap_or(""); if data.federation_repo.is_domain_blocked(domain).await? { - tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain"); + tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain"); return Ok(()); } - if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) { - data.federation_repo - .remove_follower(user_id, self.actor.inner().as_str()) - .await?; + + let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match obj_type { + "Follow" => { + if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) { + if let Ok(url) = Url::parse(obj_url) { + if let Some(user_id) = crate::urls::extract_user_id_from_url(&url) { + data.federation_repo + .remove_follower(user_id, self.actor.inner().as_str()) + .await?; + } + } + } + data.object_handler + .on_actor_removed(self.actor.inner()) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + tracing::info!(actor = %self.actor.inner(), "unfollowed"); + } + "Add" => { + let ap_id_str = self.object + .get("object") + .and_then(|o| o.get("id")) + .and_then(|id| id.as_str()) + .or_else(|| self.object.get("id").and_then(|id| id.as_str())); + + if let Some(ap_id_str) = ap_id_str { + if let Ok(ap_id) = Url::parse(ap_id_str) { + data.object_handler + .on_delete(&ap_id, self.actor.inner()) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)"); + } + } + } + other => { + tracing::debug!(kind = %other, "ignoring Undo of unknown activity type"); + } } - data.object_handler - .on_actor_removed(self.actor.inner()) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; - tracing::info!(actor = %self.actor.inner(), "unfollowed"); + Ok(()) } } @@ -430,6 +462,56 @@ impl Activity for AnnounceActivity { } } +// --- Add --- + +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(rename = "Add")] +pub struct AddType; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: AddType, + pub(crate) actor: ObjectId, + pub(crate) object: serde_json::Value, +} + +#[async_trait::async_trait] +impl Activity for AddActivity { + type DataType = FederationData; + type Error = Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let domain = self.actor().host_str().unwrap_or(""); + if data.federation_repo.is_domain_blocked(domain).await? { + tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain"); + return Ok(()); + } + let ap_id = self.id.clone(); + let actor_url = self.actor.inner().clone(); + data.object_handler + .on_create(&ap_id, &actor_url, self.object) + .await + .map_err(|e| Error::from(anyhow::anyhow!(e)))?; + tracing::info!(actor = %actor_url, "received Add activity"); + Ok(()) + } +} + // --- Block --- #[derive(Clone, Default, Debug, Serialize, Deserialize)] @@ -503,6 +585,8 @@ pub enum InboxActivities { Update(UpdateActivity), #[serde(rename = "Announce")] Announce(AnnounceActivity), + #[serde(rename = "Add")] + Add(AddActivity), #[serde(rename = "Block")] Block(BlockActivity), } diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 6e75acb..79e3303 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -34,11 +34,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec { .shared_inbox_url .as_deref() .unwrap_or(&f.actor.inbox_url); - if seen.insert(inbox_str.to_string()) { - if let Ok(url) = Url::parse(inbox_str) { + if seen.insert(inbox_str.to_string()) + && let Ok(url) = Url::parse(inbox_str) { inboxes.push(url); } - } } inboxes } @@ -228,7 +227,7 @@ impl ActivityPubService { id: undo_id, kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), - object: follow, + object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?, }; let sends = SendActivityTask::prepare( @@ -565,6 +564,131 @@ impl ActivityPubService { Ok(()) } + /// Broadcast an Add(WatchlistObject) activity to all accepted followers. + pub async fn broadcast_add_to_followers( + &self, + local_user_id: uuid::Uuid, + ap_id: Url, + object: serde_json::Value, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data + .federation_repo + .get_blocked_actors(local_user_id) + .await + .unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data + .federation_repo + .get_blocked_domains() + .await + .unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(()); + } + + let add = crate::activities::AddActivity { + id: ap_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object, + }; + let add_with_ctx = WithContext::new_default(add); + let inboxes = collect_inboxes(&accepted); + let sends = + SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Add deliveries failed"); + } + Ok(()) + } + + /// Broadcast an Undo(Add) activity to all accepted followers. + pub async fn broadcast_undo_add_to_followers( + &self, + local_user_id: uuid::Uuid, + watchlist_entry_ap_id: Url, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let followers = data.federation_repo.get_followers(local_user_id).await?; + let blocked = data + .federation_repo + .get_blocked_actors(local_user_id) + .await + .unwrap_or_default(); + let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); + let blocked_domains = data + .federation_repo + .get_blocked_domains() + .await + .unwrap_or_default(); + let blocked_domain_set: std::collections::HashSet = + blocked_domains.into_iter().map(|d| d.domain).collect(); + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .filter(|f| !blocked_set.contains(&f.actor.url)) + .filter(|f| { + let domain = url::Url::parse(&f.actor.inbox_url) + .ok() + .and_then(|u| u.host_str().map(|s| s.to_string())) + .unwrap_or_default(); + !blocked_domain_set.contains(&domain) + }) + .collect(); + + if accepted.is_empty() { + return Ok(()); + } + + let undo_id = crate::urls::activity_url(&self.base_url) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let undo = crate::activities::UndoActivity { + id: undo_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: serde_json::json!({ + "type": "Add", + "id": watchlist_entry_ap_id.as_str(), + "object": { "id": watchlist_entry_ap_id.as_str() } + }), + }; + let undo_with_ctx = WithContext::new_default(undo); + let inboxes = collect_inboxes(&accepted); + let sends = + SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed"); + } + Ok(()) + } + /// Broadcast an Update(Note) activity to all accepted followers for an edited review. pub async fn broadcast_update_to_followers( &self, @@ -812,7 +936,7 @@ impl ActivityPubService { data.federation_repo .update_following_status( local_user_id, - &target_actor_url.to_string(), + target_actor_url.as_ref(), FollowingStatus::Accepted, ) .await?; diff --git a/crates/adapters/activitypub/src/composite_handler.rs b/crates/adapters/activitypub/src/composite_handler.rs new file mode 100644 index 0000000..85203f0 --- /dev/null +++ b/crates/adapters/activitypub/src/composite_handler.rs @@ -0,0 +1,77 @@ +use std::sync::Arc; + +use activitypub_base::ApObjectHandler; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use url::Url; + +use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler}; + +pub struct CompositeObjectHandler { + pub review: Arc, + pub watchlist: Arc, +} + +#[async_trait] +impl ApObjectHandler for CompositeObjectHandler { + async fn get_local_objects_for_user( + &self, + user_id: uuid::Uuid, + ) -> anyhow::Result> { + self.review.get_local_objects_for_user(user_id).await + } + + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + before: Option>, + limit: usize, + ) -> anyhow::Result)>> { + self.review.get_local_objects_page(user_id, before, limit).await + } + + async fn on_create( + &self, + ap_id: &Url, + actor_url: &Url, + object: serde_json::Value, + ) -> anyhow::Result<()> { + if object.get("rating").is_some() { + self.review.on_create(ap_id, actor_url, object).await + } else if object.get("movieTitle").is_some() { + self.watchlist.on_create(ap_id, actor_url, object).await + } else { + tracing::debug!(ap_id = %ap_id, "ignoring Create for unknown object type"); + Ok(()) + } + } + + async fn on_update( + &self, + ap_id: &Url, + actor_url: &Url, + object: serde_json::Value, + ) -> anyhow::Result<()> { + if object.get("rating").is_some() { + self.review.on_update(ap_id, actor_url, object).await + } else { + Ok(()) + } + } + + async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> { + self.review.on_delete(ap_id, actor_url).await?; + self.watchlist.on_delete(ap_id, actor_url).await?; + Ok(()) + } + + async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { + self.review.on_actor_removed(actor_url).await?; + self.watchlist.on_actor_removed(actor_url).await?; + Ok(()) + } + + async fn count_local_posts(&self) -> anyhow::Result { + self.review.count_local_posts().await + } +} diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 12f617c..7bdb2c3 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -3,7 +3,7 @@ use domain::ports::EventHandler; use domain::{ errors::DomainError, events::DomainEvent, - ports::{MovieRepository, ReviewRepository}, + ports::{MovieRepository, ReviewRepository, WatchlistRepository}, value_objects::{ReviewId, UserId}, }; use std::sync::Arc; @@ -17,6 +17,7 @@ pub struct ActivityPubEventHandler { ap_service: Arc, movie_repository: Arc, review_repository: Arc, + watchlist_repository: Arc, base_url: String, } @@ -25,12 +26,14 @@ impl ActivityPubEventHandler { ap_service: Arc, movie_repository: Arc, review_repository: Arc, + watchlist_repository: Arc, base_url: String, ) -> Self { Self { ap_service, movie_repository, review_repository, + watchlist_repository, base_url, } } @@ -57,6 +60,21 @@ impl EventHandler for ActivityPubEventHandler { .broadcast_actor_update(user_id.value()) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), + DomainEvent::WatchlistEntryAdded { + user_id, + movie_id, + movie_title, + release_year, + external_metadata_id, + added_at, + } => self + .on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())), + DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self + .on_watchlist_removed(user_id, movie_id) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())), _ => Ok(()), } } @@ -162,4 +180,58 @@ impl ActivityPubEventHandler { .await?; Ok(()) } + + async fn on_watchlist_added( + &self, + user_id: &UserId, + movie_id: &domain::value_objects::MovieId, + movie_title: &str, + release_year: u16, + external_metadata_id: &Option, + added_at: &chrono::NaiveDateTime, + ) -> anyhow::Result<()> { + use crate::urls::watchlist_entry_url; + let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); + let actor = actor_url(&self.base_url, user_id.value()); + + let poster_url = self + .movie_repository + .get_movie_by_id(movie_id) + .await + .ok() + .flatten() + .and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value()))); + + let added_at_utc = + chrono::DateTime::::from_naive_utc_and_offset(*added_at, chrono::Utc); + let obj = crate::objects::watchlist_to_ap_object( + ap_id.clone(), + actor, + movie_title.to_string(), + release_year, + external_metadata_id.clone(), + poster_url, + added_at_utc, + &self.base_url, + ); + let json = serde_json::to_value(obj)?; + + self.ap_service + .broadcast_add_to_followers(user_id.value(), ap_id, json) + .await?; + Ok(()) + } + + async fn on_watchlist_removed( + &self, + user_id: &UserId, + movie_id: &domain::value_objects::MovieId, + ) -> anyhow::Result<()> { + use crate::urls::watchlist_entry_url; + let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); + self.ap_service + .broadcast_undo_add_to_followers(user_id.value(), ap_id) + .await?; + Ok(()) + } } diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index c6484c6..20a617c 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -1,8 +1,10 @@ +pub mod composite_handler; pub mod event_handler; pub mod objects; pub mod port; pub mod remote_review_repository; pub mod review_handler; +pub mod watchlist_handler; pub(crate) mod urls; pub mod user_adapter; @@ -25,25 +27,36 @@ pub struct ActivityPubWire { } pub async fn wire( - federation_repo: std::sync::Arc, - review_store: std::sync::Arc, - user_repo: std::sync::Arc, - movie_repo: std::sync::Arc, - review_repo: std::sync::Arc, - diary_repo: std::sync::Arc, - base_url: String, - allow_registration: bool, + federation_repo: std::sync::Arc, + review_store: std::sync::Arc, + remote_watchlist_repo: std::sync::Arc, + watchlist_repo: std::sync::Arc, + user_repo: std::sync::Arc, + movie_repo: std::sync::Arc, + review_repo: std::sync::Arc, + diary_repo: std::sync::Arc, + base_url: String, + allow_registration: bool, ) -> anyhow::Result { + let review_handler = std::sync::Arc::new(ReviewObjectHandler { + movie_repository: std::sync::Arc::clone(&movie_repo), + diary_repository: std::sync::Arc::clone(&diary_repo), + review_store, + base_url: base_url.clone(), + }); + let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler { + remote_watchlist_repo, + }); + let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler { + review: review_handler, + watchlist: watchlist_handler, + }); + let concrete = std::sync::Arc::new( ActivityPubService::new( federation_repo, std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), - std::sync::Arc::new(ReviewObjectHandler { - movie_repository: std::sync::Arc::clone(&movie_repo), - diary_repository: diary_repo, - review_store, - base_url: base_url.clone(), - }), + composite, base_url.clone(), allow_registration, "movies-diary".to_string(), @@ -57,6 +70,7 @@ pub async fn wire( std::sync::Arc::clone(&concrete), movie_repo, review_repo, + watchlist_repo, base_url, )) as std::sync::Arc; diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 81f3fa8..3ebb6c7 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -97,6 +97,72 @@ pub fn review_to_ap_object( } } +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WatchlistObject { + #[serde(rename = "type")] + pub(crate) kind: NoteType, + pub(crate) id: Url, + pub(crate) attributed_to: Url, + pub(crate) content: String, + pub(crate) published: chrono::DateTime, + pub(crate) movie_title: String, + #[serde(default)] + pub(crate) release_year: u16, + #[serde(default)] + pub(crate) external_metadata_id: Option, + #[serde(default)] + pub(crate) poster_url: Option, + #[serde(default)] + pub(crate) tag: Vec, +} + +pub fn watchlist_to_ap_object( + ap_id: Url, + actor_url: Url, + movie_title: String, + release_year: u16, + external_metadata_id: Option, + poster_url: Option, + added_at: chrono::DateTime, + base_url: &str, +) -> WatchlistObject { + let year_str = if release_year > 0 { + format!(" ({})", release_year) + } else { + String::new() + }; + let content = format!("📋 {}{} — want to watch", movie_title, year_str); + let normalized = normalize_hashtag(&movie_title); + let tag = vec![ + ApHashtag { + kind: "Hashtag".to_string(), + href: Url::parse(&format!("{}/tags/moviesdiary", base_url)) + .expect("valid base_url"), + name: "#MoviesDiary".to_string(), + }, + ApHashtag { + kind: "Hashtag".to_string(), + href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase())) + .expect("valid base_url"), + name: format!("#{}", normalized), + }, + ]; + + WatchlistObject { + kind: NoteType::default(), + id: ap_id, + attributed_to: actor_url, + content, + published: added_at, + movie_title, + release_year, + external_metadata_id, + poster_url, + tag, + } +} + #[cfg(test)] #[path = "tests/objects.rs"] mod tests; diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index b5d20a3..d75c721 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -100,11 +100,10 @@ impl ApObjectHandler for ReviewObjectHandler { let published = chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); - if let Some(cutoff) = before { - if published >= cutoff { + if let Some(cutoff) = before + && published >= cutoff { continue; } - } let ap_id = review_url(&self.base_url, review.id()); let actor_url = actor_url(&self.base_url, user_id); diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index abde28f..09c08a6 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -12,3 +12,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url { Url::parse(&format!("{}/reviews/{}", base_url, review_id.value())) .expect("base_url is always a valid URL prefix") } + +/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}` +pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url { + Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id)) + .expect("base_url is always a valid URL prefix") +} diff --git a/crates/adapters/activitypub/src/watchlist_handler.rs b/crates/adapters/activitypub/src/watchlist_handler.rs new file mode 100644 index 0000000..52ecf09 --- /dev/null +++ b/crates/adapters/activitypub/src/watchlist_handler.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use activitypub_base::ApObjectHandler; +use async_trait::async_trait; +use chrono::Utc; +use domain::{models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; +use url::Url; + +use crate::objects::WatchlistObject; + +pub struct WatchlistObjectHandler { + pub remote_watchlist_repo: Arc, +} + +#[async_trait] +impl ApObjectHandler for WatchlistObjectHandler { + async fn get_local_objects_for_user( + &self, + _user_id: uuid::Uuid, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_local_objects_page( + &self, + _user_id: uuid::Uuid, + _before: Option>, + _limit: usize, + ) -> anyhow::Result)>> { + Ok(vec![]) + } + + async fn on_create( + &self, + ap_id: &Url, + actor_url: &Url, + object: serde_json::Value, + ) -> anyhow::Result<()> { + let obj: WatchlistObject = serde_json::from_value(object)?; + let added_at = obj.published; + let entry = RemoteWatchlistEntry { + ap_id: ap_id.as_str().to_string(), + actor_url: actor_url.as_str().to_string(), + movie_title: obj.movie_title, + release_year: obj.release_year, + external_metadata_id: obj.external_metadata_id, + poster_url: obj.poster_url, + added_at, + }; + self.remote_watchlist_repo.save(entry).await?; + tracing::info!(ap_id = %ap_id, "saved remote watchlist entry"); + Ok(()) + } + + async fn on_update( + &self, + _ap_id: &Url, + _actor_url: &Url, + _object: serde_json::Value, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> { + self.remote_watchlist_repo + .remove_by_ap_id(ap_id.as_str(), actor_url.as_str()) + .await?; + tracing::info!(ap_id = %ap_id, "removed remote watchlist entry"); + Ok(()) + } + + async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { + self.remote_watchlist_repo + .remove_all_by_actor(actor_url.as_str()) + .await?; + Ok(()) + } + + async fn count_local_posts(&self) -> anyhow::Result { + Ok(0) + } +} diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 056bf59..480812a 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -119,6 +119,10 @@ impl From<&DomainEvent> for EventPayload { } } DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() }, + DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => { + // federation-only events; not serialized via EventPayload + unreachable!("watchlist events are handled by the AP event handler directly") + } } } } @@ -154,7 +158,7 @@ impl TryFrom for DomainEvent { EventPayload::MovieDeleted { movie_id, poster_path } => { let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?); let poster_path = poster_path - .map(|p| PosterPath::new(p)) + .map(PosterPath::new) .transpose() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) diff --git a/crates/adapters/image-converter/src/handler.rs b/crates/adapters/image-converter/src/handler.rs index 09ad187..01c7880 100644 --- a/crates/adapters/image-converter/src/handler.rs +++ b/crates/adapters/image-converter/src/handler.rs @@ -43,7 +43,7 @@ impl EventHandler for ImageConversionHandler { let converted = tokio::task::spawn_blocking(move || convert(bytes, format)) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))? - .map_err(|e| DomainError::InfrastructureError(e))?; + .map_err(DomainError::InfrastructureError)?; let ext = format.extension(); let new_key = format!("{key}{ext}"); diff --git a/crates/adapters/image-storage/src/lib.rs b/crates/adapters/image-storage/src/lib.rs index 37c8773..25915e8 100644 --- a/crates/adapters/image-storage/src/lib.rs +++ b/crates/adapters/image-storage/src/lib.rs @@ -7,14 +7,9 @@ use domain::{ events::DomainEvent, ports::{EventHandler, ImageStorage}, }; -use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path}; +use object_store::{ObjectStore, path::Path}; use std::sync::Arc; -fn detect_mime(bytes: &[u8]) -> &'static str { - infer::get(bytes) - .map(|t| t.mime_type()) - .unwrap_or("application/octet-stream") -} pub struct ImageStorageAdapter { store: Arc, @@ -34,12 +29,8 @@ impl ImageStorageAdapter { impl ImageStorage for ImageStorageAdapter { async fn store(&self, key: &str, image_bytes: &[u8]) -> Result { let path = Path::from(key); - let mime = detect_mime(image_bytes); - let mut attributes = Attributes::new(); - attributes.insert(Attribute::ContentType, mime.into()); - let opts = PutOptions { attributes, ..Default::default() }; self.store - .put_opts(&path, image_bytes.to_vec().into(), opts) + .put(&path, image_bytes.to_vec().into()) .await .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(key.to_string()) diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index 8c44f14..b5118bd 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -10,6 +10,9 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { DomainEvent::UserUpdated { .. } => "user.updated", DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested", DomainEvent::ImageStored { .. } => "image.stored", + DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => { + unreachable!("watchlist events are not published to NATS") + } }; format!("{prefix}.{suffix}") } diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/adapters/poster-sync/src/lib.rs index 8670086..b83292e 100644 --- a/crates/adapters/poster-sync/src/lib.rs +++ b/crates/adapters/poster-sync/src/lib.rs @@ -69,6 +69,19 @@ impl EventHandler for PosterSyncHandler { DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { (movie_id.value(), external_metadata_id.value().to_owned()) } + DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => { + // Only sync poster if the movie doesn't have one yet + let already_has_poster = self + .movie_repository + .get_movie_by_id(&MovieId::from_uuid(movie_id.value())) + .await? + .map(|m| m.poster_path().is_some()) + .unwrap_or(false); + if already_has_poster { + return Ok(()); + } + (movie_id.value(), external_metadata_id.clone()) + } _ => return Ok(()), }; diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 33683bf..90ac34d 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository; use activitypub_base::{ BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; -use domain::models::{Review, ReviewSource}; +use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; +use domain::ports::RemoteWatchlistRepository; fn datetime_to_str(dt: &NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() @@ -609,13 +610,104 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository { } } +#[async_trait] +impl RemoteWatchlistRepository for PostgresFederationRepository { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> { + sqlx::query( + "INSERT INTO ap_remote_watchlist_entries \ + (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT(ap_id) DO UPDATE SET \ + movie_title=excluded.movie_title, release_year=excluded.release_year, \ + external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", + ) + .bind(&entry.ap_id) + .bind(&entry.actor_url) + .bind(&entry.movie_title) + .bind(entry.release_year as i32) + .bind(&entry.external_metadata_id) + .bind(&entry.poster_url) + .bind(entry.added_at) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { + sqlx::query( + "DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2", + ) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_actor_url(&self, actor_url: &str) -> Result, domain::errors::DomainError> { + let rows = sqlx::query( + "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ + FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC", + ) + .bind(actor_url) + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + + rows.into_iter().map(|row| { + Ok(RemoteWatchlistEntry { + ap_id: row.try_get("ap_id").unwrap_or_default(), + actor_url: row.try_get("actor_url").unwrap_or_default(), + movie_title: row.try_get("movie_title").unwrap_or_default(), + release_year: row.try_get::("release_year").unwrap_or(0) as u16, + external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), + poster_url: row.try_get("poster_url").ok().flatten(), + added_at: row.try_get::, _>("added_at") + .unwrap_or_else(|_| chrono::Utc::now()), + }) + }).collect() + } + + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result, domain::errors::DomainError> { + let actors: Vec = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); + + let target = actors.into_iter().find(|url| { + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid + }); + + match target { + None => Ok(vec![]), + Some(actor_url) => self.get_by_actor_url(&actor_url).await, + } + } +} + pub fn wire(pool: sqlx::PgPool) -> ( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, ) { let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); ( + std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _, fed as _, diff --git a/crates/adapters/postgres/migrations/0016_watchlist.sql b/crates/adapters/postgres/migrations/0016_watchlist.sql new file mode 100644 index 0000000..dcbcef1 --- /dev/null +++ b/crates/adapters/postgres/migrations/0016_watchlist.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS watchlist_entries ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, movie_id) +); + +CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC); diff --git a/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql b/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql new file mode 100644 index 0000000..cd38bf1 --- /dev/null +++ b/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries ( + ap_id TEXT PRIMARY KEY NOT NULL, + actor_url TEXT NOT NULL, + movie_title TEXT NOT NULL, + release_year INTEGER NOT NULL, + external_metadata_id TEXT, + poster_url TEXT, + added_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor + ON ap_remote_watchlist_entries(actor_url); diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index e824050..d7369cb 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -19,10 +19,11 @@ mod models; mod persons; mod profile; mod users; +mod watchlist; use models::{ - DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow, - UserTotalsRow, datetime_to_str, + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, + MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str, }; pub use image_ref::{PostgresImageRefAdapter, create_image_ref}; @@ -31,6 +32,7 @@ pub use import_session::PostgresImportSessionRepository; pub use persons::{PostgresPersonAdapter, create_person_adapter}; pub use profile::PostgresMovieProfileRepository; pub use users::PostgresUserRepository; +pub use watchlist::PostgresWatchlistRepository; fn format_year_month(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); @@ -300,7 +302,7 @@ impl MovieRepository for PostgresRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .transpose() } @@ -314,7 +316,7 @@ impl MovieRepository for PostgresRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .transpose() } @@ -335,7 +337,7 @@ impl MovieRepository for PostgresRepository { .await .map_err(Self::map_err)? .into_iter() - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .collect() } @@ -383,21 +385,34 @@ impl MovieRepository for PostgresRepository { async fn list_movies( &self, page: &domain::models::collections::PageParams, - search: Option<&str>, - ) -> Result, DomainError> { + filter: &domain::models::MovieFilter, + ) -> Result, DomainError> { use sqlx::Row; let limit = page.limit as i64; let offset = page.offset as i64; - let pattern = search.map(|s| format!("%{}%", s.to_lowercase())); + let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); + let genre = filter.genre.as_deref(); + let language = filter.language.as_deref(); - let rows: Vec = sqlx::query_as( - "SELECT id, external_metadata_id, title, release_year, director, poster_path \ - FROM movies \ - WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \ - ORDER BY title ASC \ - LIMIT $2 OFFSET $3", + let rows: Vec = sqlx::query_as( + "SELECT \ + m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ + p.overview, p.runtime_minutes, p.original_language, p.collection_name, \ + array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \ + FROM movies m \ + LEFT JOIN movie_profiles p ON p.movie_id = m.id \ + LEFT JOIN movie_genres g ON g.movie_id = m.id \ + WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ + AND ($2::text IS NULL OR p.original_language = $2) \ + AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \ + GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ + p.overview, p.runtime_minutes, p.original_language, p.collection_name \ + ORDER BY m.title ASC \ + LIMIT $4 OFFSET $5", ) .bind(&pattern) + .bind(language) + .bind(genre) .bind(limit) .bind(offset) .fetch_all(&self.pool) @@ -405,17 +420,25 @@ impl MovieRepository for PostgresRepository { .map_err(Self::map_err)?; let total: i64 = sqlx::query( - "SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)", + "SELECT COUNT(DISTINCT m.id) \ + FROM movies m \ + LEFT JOIN movie_profiles p ON p.movie_id = m.id \ + WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ + AND ($2::text IS NULL OR p.original_language = $2) \ + AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))", ) .bind(&pattern) + .bind(language) + .bind(genre) .fetch_one(&self.pool) .await .map_err(Self::map_err)? .try_get(0) .unwrap_or(0); - let items = rows.into_iter() - .map(|r| r.to_domain()) + let items = rows + .into_iter() + .map(|r| r.into_domain()) .collect::, _>>()?; Ok(domain::models::collections::Paginated { @@ -480,7 +503,7 @@ impl ReviewRepository for PostgresRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(ReviewRow::to_domain) + .map(ReviewRow::into_domain) .transpose() } @@ -540,7 +563,7 @@ impl DiaryRepository for PostgresRepository { let items = rows .into_iter() - .map(DiaryRow::to_domain) + .map(DiaryRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -681,7 +704,7 @@ impl DiaryRepository for PostgresRepository { let items = rows .into_iter() - .map(FeedRow::to_domain) + .map(FeedRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -704,7 +727,7 @@ impl DiaryRepository for PostgresRepository { .await .map_err(Self::map_err)? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? - .to_domain()?; + .into_domain()?; let viewings = sqlx::query_as::<_, ReviewRow>( "SELECT id, movie_id, user_id, rating, comment, @@ -718,7 +741,7 @@ impl DiaryRepository for PostgresRepository { .await .map_err(Self::map_err)? .into_iter() - .map(ReviewRow::to_domain) + .map(ReviewRow::into_domain) .collect::, _>>()?; Ok(ReviewHistory::new(movie, viewings)) @@ -742,7 +765,7 @@ impl DiaryRepository for PostgresRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::to_domain).collect() + rows.into_iter().map(DiaryRow::into_domain).collect() } async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { @@ -763,7 +786,7 @@ impl DiaryRepository for PostgresRepository { .fetch_one(&self.pool) .await .map_err(Self::map_err) - .map(MovieStatsRow::to_domain) + .map(MovieStatsRow::into_domain) } async fn get_movie_social_feed( @@ -808,7 +831,7 @@ impl DiaryRepository for PostgresRepository { let items = rows .into_iter() - .map(FeedRow::to_domain) + .map(FeedRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -918,6 +941,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, )> { use anyhow::Context; @@ -934,6 +958,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())); + let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())); Ok(( pool.clone(), @@ -945,5 +970,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( import_session_repo as _, import_profile_repo as _, movie_profile_repo as _, + watchlist_repo as _, )) } diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs index 81bf367..f5c127c 100644 --- a/crates/adapters/postgres/src/models.rs +++ b/crates/adapters/postgres/src/models.rs @@ -1,7 +1,7 @@ use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary}, + models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary}, value_objects::{ Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, @@ -20,7 +20,7 @@ pub(crate) struct MovieRow { } impl MovieRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let id = MovieId::from_uuid(parse_uuid(&self.id)?); let external_metadata_id = self .external_metadata_id @@ -40,6 +40,43 @@ impl MovieRow { } } +#[derive(sqlx::FromRow)] +pub(crate) struct MovieSummaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub genres: Option>, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + +impl MovieSummaryRow { + pub fn into_domain(self) -> Result { + let movie = MovieRow { + id: self.id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + Ok(MovieSummary { + movie, + genres: self.genres.unwrap_or_default(), + runtime_minutes: self.runtime_minutes.map(|v| v as u32), + original_language: self.original_language, + overview: self.overview, + collection_name: self.collection_name, + }) + } +} + #[derive(sqlx::FromRow)] pub(crate) struct ReviewRow { pub id: String, @@ -53,7 +90,7 @@ pub(crate) struct ReviewRow { } impl ReviewRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let id = ReviewId::from_uuid(parse_uuid(&self.id)?); let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); @@ -90,7 +127,7 @@ pub(crate) struct DiaryRow { } impl DiaryRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let movie = MovieRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -99,7 +136,7 @@ impl DiaryRow { director: self.director, poster_path: self.poster_path, } - .to_domain()?; + .into_domain()?; let review = ReviewRow { id: self.review_id, movie_id: self.movie_id, @@ -110,7 +147,7 @@ impl DiaryRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, } - .to_domain()?; + .into_domain()?; Ok(DiaryEntry::new(movie, review)) } } @@ -135,7 +172,7 @@ pub(crate) struct FeedRow { } impl FeedRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let diary = DiaryRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -152,7 +189,7 @@ impl FeedRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, } - .to_domain()?; + .into_domain()?; Ok(FeedEntry::new(diary, self.user_email)) } } @@ -170,7 +207,7 @@ pub(crate) struct MovieStatsRow { } impl MovieStatsRow { - pub fn to_domain(self) -> domain::models::MovieStats { + pub fn into_domain(self) -> domain::models::MovieStats { domain::models::MovieStats { total_count: self.total_count as u64, avg_rating: self.avg_rating, @@ -195,7 +232,7 @@ pub(crate) struct UserSummaryRow { } impl UserSummaryRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { Ok(UserSummary::new( UserId::from_uuid(parse_uuid(&self.id)?), Email::new(self.email)?, diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs index 244b60f..722deb1 100644 --- a/crates/adapters/postgres/src/users.rs +++ b/crates/adapters/postgres/src/users.rs @@ -231,7 +231,7 @@ impl UserRepository for PostgresUserRepository { .await .map_err(Self::map_err)? .into_iter() - .map(UserSummaryRow::to_domain) + .map(UserSummaryRow::into_domain) .collect() } } diff --git a/crates/adapters/postgres/src/watchlist.rs b/crates/adapters/postgres/src/watchlist.rs new file mode 100644 index 0000000..ba010c5 --- /dev/null +++ b/crates/adapters/postgres/src/watchlist.rs @@ -0,0 +1,169 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, + ports::WatchlistRepository, + value_objects::{MovieId, UserId, WatchlistEntryId}, +}; +use sqlx::{PgPool, Row}; + +use crate::models::{parse_uuid, parse_datetime, MovieRow}; + +pub struct PostgresWatchlistRepository { + pool: PgPool, +} + +impl PostgresWatchlistRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl WatchlistRepository for PostgresWatchlistRepository { + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + let id = entry.id.value().to_string(); + let user_id = entry.user_id.value().to_string(); + let movie_id = entry.movie_id.value().to_string(); + let added_at = entry.added_at; + + sqlx::query( + "INSERT INTO watchlist_entries (id, user_id, movie_id, added_at) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (user_id, movie_id) DO NOTHING", + ) + .bind(&id) + .bind(&user_id) + .bind(&movie_id) + .bind(added_at) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(()) + } + + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + + let result = sqlx::query( + "DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", + ) + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + if result.rows_affected() == 0 { + return Err(DomainError::NotFound(format!( + "Watchlist entry for movie {} not found", + mid + ))); + } + Ok(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + let result = sqlx::query( + "DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", + ) + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(result.rows_affected() > 0) + } + + async fn get_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let limit = page.limit as i64; + let offset = page.offset as i64; + + let rows = sqlx::query( + "SELECT w.id, w.user_id, w.movie_id, \ + to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, \ + m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \ + m.director, m.poster_path \ + FROM watchlist_entries w \ + JOIN movies m ON m.id = w.movie_id \ + WHERE w.user_id = $1 \ + ORDER BY w.added_at DESC \ + LIMIT $2 OFFSET $3", + ) + .bind(&uid) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + + let items = rows + .into_iter() + .map(|row| { + let entry = WatchlistEntry { + id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), + user_id: UserId::from_uuid(parse_uuid(&row.try_get::("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), + movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?), + added_at: parse_datetime(&row.try_get::("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?, + }; + let movie = MovieRow { + id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, + } + .into_domain()?; + Ok(WatchlistWithMovie { entry, movie }) + }) + .collect::, DomainError>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", + ) + .bind(&uid) + .bind(&mid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(count > 0) + } +} diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 3d9fa25..f35c5ad 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository; use activitypub_base::{ BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; -use domain::models::{Review, ReviewSource}; +use domain::models::{Review, ReviewSource, RemoteWatchlistEntry}; +use domain::ports::RemoteWatchlistRepository; fn datetime_to_str(dt: &NaiveDateTime) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() @@ -672,13 +673,107 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository { } } +#[async_trait] +impl RemoteWatchlistRepository for SqliteFederationRepository { + async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> { + sqlx::query( + "INSERT INTO ap_remote_watchlist_entries \ + (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT(ap_id) DO UPDATE SET \ + movie_title=excluded.movie_title, release_year=excluded.release_year, \ + external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", + ) + .bind(&entry.ap_id) + .bind(&entry.actor_url) + .bind(&entry.movie_title) + .bind(entry.release_year as i64) + .bind(&entry.external_metadata_id) + .bind(&entry.poster_url) + .bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string()) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> { + sqlx::query( + "DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?", + ) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_actor_url(&self, actor_url: &str) -> Result, domain::errors::DomainError> { + let rows = sqlx::query( + "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ + FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC", + ) + .bind(actor_url) + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + + rows.into_iter().map(|row| { + let added_at_str: String = row.try_get("added_at").unwrap_or_default(); + let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S") + .map(|dt| chrono::DateTime::::from_naive_utc_and_offset(dt, chrono::Utc)) + .unwrap_or_else(|_| chrono::Utc::now()); + Ok(RemoteWatchlistEntry { + ap_id: row.try_get("ap_id").unwrap_or_default(), + actor_url: row.try_get("actor_url").unwrap_or_default(), + movie_title: row.try_get("movie_title").unwrap_or_default(), + release_year: row.try_get::("release_year").unwrap_or(0) as u16, + external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), + poster_url: row.try_get("poster_url").ok().flatten(), + added_at, + }) + }).collect() + } + + async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> { + sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?") + .bind(actor_url) + .execute(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?; + Ok(()) + } + + async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result, domain::errors::DomainError> { + let actors: Vec = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") + .fetch_all(&self.pool) + .await + .map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))? + .into_iter() + .filter_map(|row| row.try_get::("actor_url").ok()) + .collect(); + + let target = actors.into_iter().find(|url| { + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid + }); + + match target { + None => Ok(vec![]), + Some(actor_url) => self.get_by_actor_url(&actor_url).await, + } + } +} + pub fn wire(pool: sqlx::SqlitePool) -> ( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, ) { let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); ( + std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _, fed as _, diff --git a/crates/adapters/sqlite-search/src/lib.rs b/crates/adapters/sqlite-search/src/lib.rs index 4469b1c..9fe4fae 100644 --- a/crates/adapters/sqlite-search/src/lib.rs +++ b/crates/adapters/sqlite-search/src/lib.rs @@ -160,7 +160,7 @@ impl SqliteSearchAdapter { } let total: u64 = if let Some(text) = &query.text { - let fts_query = format!("{}*", text.replace('"', "").replace('*', "")); + let fts_query = format!("{}*", text.replace(['"', '*'], "")); let count: i64 = sqlx::query_scalar( "SELECT COUNT(DISTINCT m.id) FROM movies_fts fts @@ -198,7 +198,7 @@ impl SqliteSearchAdapter { }; let rows: Vec = if let Some(text) = &query.text { - let fts_query = format!("{}*", text.replace('"', "").replace('*', "")); + let fts_query = format!("{}*", text.replace(['"', '*'], "")); sqlx::query_as::<_, Row>( "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, GROUP_CONCAT(DISTINCT mg.name) AS genres @@ -273,7 +273,7 @@ impl SqliteSearchAdapter { let limit = query.page.limit as i64; let offset = query.page.offset as i64; - let fts_query = format!("{}*", text.replace('"', "").replace('*', "")); + let fts_query = format!("{}*", text.replace(['"', '*'], "")); let total: u64 = { let count: i64 = sqlx::query_scalar( diff --git a/crates/adapters/sqlite/migrations/0016_watchlist.sql b/crates/adapters/sqlite/migrations/0016_watchlist.sql new file mode 100644 index 0000000..c084fdb --- /dev/null +++ b/crates/adapters/sqlite/migrations/0016_watchlist.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS watchlist_entries ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, + added_at TEXT NOT NULL, + UNIQUE(user_id, movie_id) +); + +CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC); diff --git a/crates/adapters/sqlite/migrations/0017_ap_remote_watchlist.sql b/crates/adapters/sqlite/migrations/0017_ap_remote_watchlist.sql new file mode 100644 index 0000000..fb743cd --- /dev/null +++ b/crates/adapters/sqlite/migrations/0017_ap_remote_watchlist.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries ( + ap_id TEXT PRIMARY KEY NOT NULL, + actor_url TEXT NOT NULL, + movie_title TEXT NOT NULL, + release_year INTEGER NOT NULL, + external_metadata_id TEXT, + poster_url TEXT, + added_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor + ON ap_remote_watchlist_entries(actor_url); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 1765719..c470588 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -20,10 +20,11 @@ mod models; mod persons; mod profile; mod users; +mod watchlist; use models::{ - DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow, - UserTotalsRow, datetime_to_str, + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, + MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str, }; pub use image_ref::{SqliteImageRefAdapter, create_image_ref}; @@ -32,6 +33,7 @@ pub use import_session::SqliteImportSessionRepository; pub use persons::{SqlitePersonAdapter, create_person_adapter}; pub use profile::SqliteMovieProfileRepository; pub use users::SqliteUserRepository; +pub use watchlist::SqliteWatchlistRepository; fn format_year_month(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); @@ -307,7 +309,7 @@ impl MovieRepository for SqliteMovieRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .transpose() } @@ -322,7 +324,7 @@ impl MovieRepository for SqliteMovieRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .transpose() } @@ -344,7 +346,7 @@ impl MovieRepository for SqliteMovieRepository { .await .map_err(Self::map_err)? .into_iter() - .map(MovieRow::to_domain) + .map(MovieRow::into_domain) .collect() } @@ -391,22 +393,37 @@ impl MovieRepository for SqliteMovieRepository { async fn list_movies( &self, page: &domain::models::collections::PageParams, - search: Option<&str>, - ) -> Result, DomainError> { + filter: &domain::models::MovieFilter, + ) -> Result, DomainError> { use sqlx::Row; let limit = page.limit as i64; let offset = page.offset as i64; - let pattern = search.map(|s| format!("%{}%", s.to_lowercase())); + let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase())); + let genre = filter.genre.as_deref(); + let language = filter.language.as_deref(); - let rows: Vec = sqlx::query_as( - "SELECT id, external_metadata_id, title, release_year, director, poster_path \ - FROM movies \ - WHERE (? IS NULL OR LOWER(title) LIKE ?) \ - ORDER BY title ASC \ + let rows: Vec = sqlx::query_as( + "SELECT \ + m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ + p.overview, p.runtime_minutes, p.original_language, p.collection_name, \ + GROUP_CONCAT(g.name) AS genres \ + FROM movies m \ + LEFT JOIN movie_profiles p ON p.movie_id = m.id \ + LEFT JOIN movie_genres g ON g.movie_id = m.id \ + WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \ + AND (? IS NULL OR p.original_language = ?) \ + AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?))) \ + GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ + p.overview, p.runtime_minutes, p.original_language, p.collection_name \ + ORDER BY m.title ASC \ LIMIT ? OFFSET ?", ) .bind(&pattern) .bind(&pattern) + .bind(language) + .bind(language) + .bind(genre) + .bind(genre) .bind(limit) .bind(offset) .fetch_all(&self.pool) @@ -414,18 +431,28 @@ impl MovieRepository for SqliteMovieRepository { .map_err(Self::map_err)?; let total: i64 = sqlx::query( - "SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)", + "SELECT COUNT(DISTINCT m.id) \ + FROM movies m \ + LEFT JOIN movie_profiles p ON p.movie_id = m.id \ + WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \ + AND (? IS NULL OR p.original_language = ?) \ + AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?)))", ) .bind(&pattern) .bind(&pattern) + .bind(language) + .bind(language) + .bind(genre) + .bind(genre) .fetch_one(&self.pool) .await .map_err(Self::map_err)? .try_get(0) .unwrap_or(0); - let items = rows.into_iter() - .map(|r| r.to_domain()) + let items = rows + .into_iter() + .map(|r| r.into_domain()) .collect::, _>>()?; Ok(domain::models::collections::Paginated { @@ -488,7 +515,7 @@ impl ReviewRepository for SqliteMovieRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)? - .map(ReviewRow::to_domain) + .map(ReviewRow::into_domain) .transpose() } @@ -547,7 +574,7 @@ impl DiaryRepository for SqliteMovieRepository { let items = rows .into_iter() - .map(DiaryRow::to_domain) + .map(DiaryRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -674,7 +701,7 @@ impl DiaryRepository for SqliteMovieRepository { let items = rows .into_iter() - .map(FeedRow::to_domain) + .map(FeedRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -698,7 +725,7 @@ impl DiaryRepository for SqliteMovieRepository { .await .map_err(Self::map_err)? .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? - .to_domain()?; + .into_domain()?; let viewings = sqlx::query_as!( ReviewRow, @@ -710,7 +737,7 @@ impl DiaryRepository for SqliteMovieRepository { .await .map_err(Self::map_err)? .into_iter() - .map(ReviewRow::to_domain) + .map(ReviewRow::into_domain) .collect::, _>>()?; Ok(ReviewHistory::new(movie, viewings)) @@ -732,7 +759,7 @@ impl DiaryRepository for SqliteMovieRepository { .await .map_err(Self::map_err)?; - rows.into_iter().map(DiaryRow::to_domain).collect() + rows.into_iter().map(DiaryRow::into_domain).collect() } async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { @@ -753,7 +780,7 @@ impl DiaryRepository for SqliteMovieRepository { .fetch_one(&self.pool) .await .map_err(Self::map_err) - .map(MovieStatsRow::to_domain) + .map(MovieStatsRow::into_domain) } async fn get_movie_social_feed( @@ -796,7 +823,7 @@ impl DiaryRepository for SqliteMovieRepository { let items = rows .into_iter() - .map(FeedRow::to_domain) + .map(FeedRow::into_domain) .collect::, _>>()?; Ok(Paginated { @@ -909,6 +936,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, )> { use std::str::FromStr; use anyhow::Context; @@ -932,6 +960,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())); let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())); let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())); + let watchlist_repo = std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())); Ok(( pool.clone(), @@ -943,6 +972,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( import_session_repo as _, import_profile_repo as _, movie_profile_repo as _, + watchlist_repo as _, )) } diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index c545f8f..b90f0e1 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -1,10 +1,10 @@ use chrono::NaiveDateTime; use domain::{ errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary}, + models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie}, value_objects::{ Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, - ReviewId, UserId, + ReviewId, UserId, WatchlistEntryId, }, }; use uuid::Uuid; @@ -20,7 +20,7 @@ pub(crate) struct MovieRow { } impl MovieRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let id = MovieId::from_uuid(parse_uuid(&self.id)?); let external_metadata_id = self .external_metadata_id @@ -40,6 +40,47 @@ impl MovieRow { } } +#[derive(sqlx::FromRow)] +pub(crate) struct MovieSummaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub genres: Option, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + +impl MovieSummaryRow { + pub fn into_domain(self) -> Result { + let movie = MovieRow { + id: self.id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + let genres = self + .genres + .map(|g| g.split(',').map(str::to_string).collect()) + .unwrap_or_default(); + Ok(MovieSummary { + movie, + genres, + runtime_minutes: self.runtime_minutes.map(|v| v as u32), + original_language: self.original_language, + overview: self.overview, + collection_name: self.collection_name, + }) + } +} + #[derive(sqlx::FromRow)] pub(crate) struct ReviewRow { pub id: String, @@ -53,7 +94,7 @@ pub(crate) struct ReviewRow { } impl ReviewRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let id = ReviewId::from_uuid(parse_uuid(&self.id)?); let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); @@ -91,7 +132,7 @@ pub(crate) struct DiaryRow { } impl DiaryRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let movie = MovieRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -100,7 +141,7 @@ impl DiaryRow { director: self.director, poster_path: self.poster_path, } - .to_domain()?; + .into_domain()?; let review = ReviewRow { id: self.review_id, @@ -112,7 +153,7 @@ impl DiaryRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, } - .to_domain()?; + .into_domain()?; Ok(DiaryEntry::new(movie, review)) } @@ -131,7 +172,7 @@ pub(crate) struct MovieStatsRow { } impl MovieStatsRow { - pub fn to_domain(self) -> domain::models::MovieStats { + pub fn into_domain(self) -> domain::models::MovieStats { domain::models::MovieStats { total_count: self.total_count as u64, avg_rating: self.avg_rating, @@ -168,7 +209,7 @@ pub(crate) struct FeedRow { } impl FeedRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { let diary = DiaryRow { id: self.id, external_metadata_id: self.external_metadata_id, @@ -185,7 +226,7 @@ impl FeedRow { created_at: self.created_at, remote_actor_url: self.remote_actor_url, } - .to_domain()?; + .into_domain()?; Ok(FeedEntry::new(diary, self.user_email)) } } @@ -199,7 +240,7 @@ pub(crate) struct UserSummaryRow { } impl UserSummaryRow { - pub fn to_domain(self) -> Result { + pub fn into_domain(self) -> Result { Ok(UserSummary::new( UserId::from_uuid(parse_uuid(&self.id)?), Email::new(self.email)?, @@ -228,6 +269,41 @@ pub(crate) struct MonthlyRatingRow { pub count: i64, } +#[derive(sqlx::FromRow)] +pub(crate) struct WatchlistRow { + pub id: String, + pub user_id: String, + pub movie_id: String, + pub added_at: String, + pub m_id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl WatchlistRow { + pub fn into_domain(self) -> Result { + let entry = WatchlistEntry { + id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?), + user_id: UserId::from_uuid(parse_uuid(&self.user_id)?), + movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?), + added_at: parse_datetime(&self.added_at)?, + }; + let movie = MovieRow { + id: self.m_id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .into_domain()?; + Ok(WatchlistWithMovie { entry, movie }) + } +} + pub(crate) fn parse_uuid(s: &str) -> Result { Uuid::parse_str(s) .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 05cde02..fd6abfc 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -202,7 +202,7 @@ impl UserRepository for SqliteUserRepository { .await .map_err(Self::map_err)? .into_iter() - .map(UserSummaryRow::to_domain) + .map(UserSummaryRow::into_domain) .collect() } } diff --git a/crates/adapters/sqlite/src/watchlist.rs b/crates/adapters/sqlite/src/watchlist.rs new file mode 100644 index 0000000..aac455c --- /dev/null +++ b/crates/adapters/sqlite/src/watchlist.rs @@ -0,0 +1,154 @@ +use async_trait::async_trait; +use domain::{ + errors::DomainError, + models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}}, + ports::WatchlistRepository, + value_objects::{MovieId, UserId}, +}; +use sqlx::{Row, SqlitePool}; + +use crate::models::{WatchlistRow, datetime_to_str}; + +pub struct SqliteWatchlistRepository { + pool: SqlitePool, +} + +impl SqliteWatchlistRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("Database error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl WatchlistRepository for SqliteWatchlistRepository { + async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { + let id = entry.id.value().to_string(); + let user_id = entry.user_id.value().to_string(); + let movie_id = entry.movie_id.value().to_string(); + let added_at = datetime_to_str(&entry.added_at); + + sqlx::query( + "INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \ + VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(&user_id) + .bind(&movie_id) + .bind(&added_at) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(()) + } + + async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + + let result = sqlx::query( + "DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", + ) + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + if result.rows_affected() == 0 { + return Err(DomainError::NotFound(format!( + "Watchlist entry for movie {} not found", + mid + ))); + } + Ok(()) + } + + async fn remove_if_present( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + let result = sqlx::query( + "DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", + ) + .bind(&uid) + .bind(&mid) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(result.rows_affected() > 0) + } + + async fn get_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let limit = page.limit as i64; + let offset = page.offset as i64; + + let rows: Vec = sqlx::query_as( + "SELECT w.id, w.user_id, w.movie_id, w.added_at, \ + m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \ + m.director, m.poster_path \ + FROM watchlist_entries w \ + JOIN movies m ON m.id = w.movie_id \ + WHERE w.user_id = ? \ + ORDER BY w.added_at DESC \ + LIMIT ? OFFSET ?", + ) + .bind(&uid) + .bind(limit) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + + let total: i64 = sqlx::query( + "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)? + .try_get(0) + .unwrap_or(0); + + let items = rows + .into_iter() + .map(|r| r.into_domain()) + .collect::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + let uid = user_id.value().to_string(); + let mid = movie_id.value().to_string(); + let count: i64 = sqlx::query( + "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ? AND movie_id = ?", + ) + .bind(&uid) + .bind(&mid) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err)? + .try_get(0) + .unwrap_or(0); + Ok(count > 0) + } +} diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index b4b65a4..22894d9 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -3,7 +3,7 @@ use application::ports::{ BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, - ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, + ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData, }; use askama::Template; use chrono::Datelike; @@ -101,13 +101,28 @@ struct MovieDetailTemplate<'a> { ctx: &'a HtmlPageContext, movie: &'a domain::models::Movie, stats: &'a domain::models::MovieStats, + profile: Option<&'a domain::models::MovieProfile>, reviews: &'a [domain::models::FeedEntry], + on_watchlist: bool, current_offset: u32, has_more: bool, limit: u32, histogram_max: u64, } +#[derive(Template)] +#[template(path = "watchlist.html")] +struct WatchlistTemplate<'a> { + ctx: &'a HtmlPageContext, + owner_id: uuid::Uuid, + display_entries: &'a [application::ports::WatchlistDisplayEntry], + current_offset: u32, + has_more: bool, + limit: u32, + is_owner: bool, + error: Option, +} + impl<'a> ActivityFeedTemplate<'a> { pub fn filter_qs(&self) -> String { let mut parts = vec![ @@ -358,6 +373,7 @@ struct ImportPreviewTemplate<'a> { rows: &'a [ImportPreviewRow], } +#[derive(Default)] pub struct AskamaHtmlRenderer; impl AskamaHtmlRenderer { @@ -374,7 +390,7 @@ impl HtmlRenderer for AskamaHtmlRenderer { ) -> Result { let has_more = (data.offset + data.limit) < data.total_count as u32; let (total_pages, current_page) = if data.limit > 0 { - let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32; + let tp = data.total_count.div_ceil(data.limit as u64) as u32; (tp, data.offset / data.limit) } else { (0, 0) @@ -420,16 +436,8 @@ impl HtmlRenderer for AskamaHtmlRenderer { fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result { let limit = data.limit; - let total_pages = if limit > 0 { - ((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32 - } else { - 0 - }; - let current_page = if limit > 0 { - data.current_offset / limit - } else { - 0 - }; + let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32; + let current_page = data.current_offset.checked_div(limit).unwrap_or(0); ActivityFeedTemplate { entries: &data.entries.items, current_offset: data.current_offset, @@ -496,7 +504,7 @@ impl HtmlRenderer for AskamaHtmlRenderer { let heatmap = data .history .as_deref() - .map(|h| build_heatmap(h)) + .map(build_heatmap) .unwrap_or_default(); let profile_display_name = data .profile_user_email @@ -521,18 +529,10 @@ impl HtmlRenderer for AskamaHtmlRenderer { .entries .as_ref() .map(|e| { - if e.limit > 0 { - ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 - } else { - 0 - } + e.total_count.div_ceil(e.limit.max(1) as u64) as u32 }) .unwrap_or(0); - let current_page = if data.limit > 0 { - data.current_offset / data.limit - } else { - 0 - }; + let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0); let avg_rating_display = data .stats .avg_rating @@ -594,7 +594,9 @@ impl HtmlRenderer for AskamaHtmlRenderer { ctx: &data.ctx, movie: &data.movie, stats: &data.stats, + profile: data.profile.as_ref(), reviews: &data.reviews.items, + on_watchlist: data.on_watchlist, current_offset: data.current_offset, has_more: data.has_more, limit: data.limit, @@ -604,6 +606,21 @@ impl HtmlRenderer for AskamaHtmlRenderer { .map_err(|e| e.to_string()) } + fn render_watchlist_page(&self, data: WatchlistPageData) -> Result { + WatchlistTemplate { + ctx: &data.ctx, + owner_id: data.owner_id, + display_entries: &data.display_entries, + current_offset: data.current_offset, + has_more: data.has_more, + limit: data.limit, + is_owner: data.is_owner, + error: data.error, + } + .render() + .map_err(|e| e.to_string()) + } + fn render_following_page(&self, data: FollowingPageData) -> Result { FollowingTemplate { ctx: data.ctx, diff --git a/crates/adapters/template-askama/templates/base.html b/crates/adapters/template-askama/templates/base.html index 8ea0eac..081a79c 100644 --- a/crates/adapters/template-askama/templates/base.html +++ b/crates/adapters/template-askama/templates/base.html @@ -22,6 +22,9 @@ rel="stylesheet" /> + + + diff --git a/crates/adapters/template-askama/templates/movie_detail.html b/crates/adapters/template-askama/templates/movie_detail.html index fcc076f..865822f 100644 --- a/crates/adapters/template-askama/templates/movie_detail.html +++ b/crates/adapters/template-askama/templates/movie_detail.html @@ -2,6 +2,7 @@ {% block content %}
+ {# ── Hero ── #}
{% if let Some(poster) = movie.poster_path() %}
@@ -11,15 +12,46 @@ {{ movie.title().value() }} ({{ movie.release_year().value() }})
- {% if let Some(dir) = movie.director() %} -
{{ dir }}
+
+ {% if let Some(dir) = movie.director() %}{{ dir }}{% endif %} + {% if let Some(p) = profile %} + {% if let Some(runtime) = p.runtime_minutes %}· {{ runtime }} min{% endif %} + {% if let Some(lang) = &p.original_language %}· {{ lang|upper }}{% endif %} + {% endif %} +
+ {% if let Some(p) = profile %} + {% if !p.genres.is_empty() %} +
+ {% for g in &p.genres %}{{ g.name }}{% endfor %} +
+ {% endif %} + {% if let Some(tagline) = &p.tagline %}{% if !tagline.is_empty() %} +
"{{ tagline }}"
+ {% endif %}{% endif %} {% endif %}
+ Log a review + {% if ctx.user_id.is_some() %} + {% if on_watchlist %} +
+ + + +
+ {% else %} +
+ + + + +
+ {% endif %} + {% endif %}
+ {# ── Stats ── #}
{% if let Some(avg) = stats.avg_rating %}
@@ -47,6 +79,46 @@
+ {% if let Some(p) = profile %} + + {# ── Overview ── #} + {% if let Some(overview) = &p.overview %}{% if !overview.is_empty() %} +

{{ overview }}

+ {% endif %}{% endif %} + + {# ── Cast ── #} + {% if !p.cast.is_empty() %} + +
+ {% for (i, member) in p.cast.iter().enumerate() %}{% if i < 10 %} +
+ {% if let Some(path) = &member.profile_path %} + {{ member.name }} + {% else %} + {{ member.name }} + {% endif %} +
{{ member.name }}
+
{{ member.character }}
+
+ {% endif %}{% endfor %} +
+ {% endif %} + + {# ── Crew ── #} + {% if !p.crew.is_empty() %} + +
    + {% for member in &p.crew %} + {% if member.job == "Screenplay" || member.job == "Story" || member.job == "Original Music Composer" || member.job == "Director of Photography" %} +
  • {{ member.job }}{{ member.name }}
  • + {% endif %} + {% endfor %} +
+ {% endif %} + + {% endif %} + + {# ── Reviews ── #}
{% for entry in reviews %} diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index 80db6ca..5b94e98 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -68,6 +68,7 @@

Account

+ Watchlist Profile settings Blocked users {% if ctx.is_admin %} diff --git a/crates/adapters/template-askama/templates/watchlist.html b/crates/adapters/template-askama/templates/watchlist.html new file mode 100644 index 0000000..110079e --- /dev/null +++ b/crates/adapters/template-askama/templates/watchlist.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block content %} +
+
Watchlist
+ + {% if is_owner %} + {% if let Some(err) = &error %} +

{{ err }}

+ {% endif %} +
+
+ + +
+
+ + +
+ + + +
+ {% endif %} + + {% if display_entries.is_empty() %} +

No movies on the watchlist yet.

+ {% else %} +
+ {% for entry in display_entries %} +
+ {% if let Some(url) = &entry.poster_url %} +
+ {% endif %} +
+
+ {% if let Some(url) = &entry.movie_url %} + {{ entry.movie_title }} + {% else %} + {{ entry.movie_title }} + {% endif %} + ({{ entry.release_year }}) +
+
+ Added {{ entry.added_at }} +
+ {% if let Some(remove_url) = &entry.remove_url %} +
+ + + +
+ {% endif %} +
+
+ {% endfor %} +
+ + + {% endif %} +
+{% endblock %} diff --git a/crates/api-types/src/lib.rs b/crates/api-types/src/lib.rs index caa4180..b9a5056 100644 --- a/crates/api-types/src/lib.rs +++ b/crates/api-types/src/lib.rs @@ -6,6 +6,7 @@ pub mod movies; pub mod search; pub mod social; pub mod users; +pub mod watchlist; pub use auth::*; pub use common::*; @@ -14,3 +15,4 @@ pub use import::*; pub use movies::*; pub use social::*; pub use users::*; +pub use watchlist::*; diff --git a/crates/api-types/src/movies.rs b/crates/api-types/src/movies.rs index 72119b0..7e8f82c 100644 --- a/crates/api-types/src/movies.rs +++ b/crates/api-types/src/movies.rs @@ -10,6 +10,10 @@ pub struct MoviesQueryParams { pub offset: Option, /// Optional title filter (case-insensitive substring match) pub search: Option, + /// Filter by genre name (case-insensitive) + pub genre: Option, + /// Filter by ISO language code (e.g. "en") + pub language: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -79,6 +83,11 @@ pub struct MovieDto { pub release_year: u16, pub director: Option, pub poster_path: Option, + pub genres: Vec, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/api-types/src/watchlist.rs b/crates/api-types/src/watchlist.rs new file mode 100644 index 0000000..2a28471 --- /dev/null +++ b/crates/api-types/src/watchlist.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::movies::MovieDto; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct WatchlistEntryDto { + pub id: Uuid, + pub movie: MovieDto, + pub added_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct WatchlistResponse { + pub items: Vec, + pub total_count: u64, + pub limit: u32, + pub offset: u32, +} + +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct AddToWatchlistRequest { + pub movie_id: Uuid, +} + +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct WatchlistStatusResponse { + pub on_watchlist: bool, +} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index d9fe7aa..8c8b792 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -14,6 +14,7 @@ tokio = { workspace = true } [features] xlsx = [] +federation = [] [dev-dependencies] tokio = { workspace = true } diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index 313a067..2539d7d 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -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, pub external_metadata_id: Option, - pub manual_title: Option, pub manual_release_year: Option, pub manual_director: Option, +} +pub struct LogReviewCommand { pub user_id: Uuid, + pub input: MovieInput, pub rating: u8, pub comment: Option, 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, +} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 046f0e8..5767b36 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -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, pub search_port: Arc, pub search_command: Arc, + pub watchlist_repository: Arc, + #[cfg(feature = "federation")] + pub remote_watchlist_repository: Arc, pub config: AppConfig, } diff --git a/crates/application/src/movie_resolver.rs b/crates/application/src/movie_resolver.rs index 2cfbf95..42181d3 100644 --- a/crates/application/src/movie_resolver.rs +++ b/crates/application/src/movie_resolver.rs @@ -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, 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, 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, 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, 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))) } } diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 9c452d1..c65ff6b 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -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, + pub profile: Option, + 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, + 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, + pub added_at: String, + /// /watchlist/{movie_id}/remove for owner; None for remote or non-owner + pub remove_url: Option, +} + +pub struct WatchlistPageData { + pub ctx: HtmlPageContext, + pub owner_id: uuid::Uuid, + pub display_entries: Vec, + pub current_offset: u32, + pub has_more: bool, + pub limit: u32, + pub is_owner: bool, + pub error: Option, +} + pub struct ImportUploadPageData { pub ctx: HtmlPageContext, pub profiles: Vec, @@ -201,6 +227,7 @@ pub trait HtmlRenderer: Send + Sync { ) -> Result; fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result; fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result; + fn render_watchlist_page(&self, data: WatchlistPageData) -> Result; } pub trait RssFeedRenderer: Send + Sync { diff --git a/crates/application/src/queries.rs b/crates/application/src/queries.rs index 61fd408..86b9990 100644 --- a/crates/application/src/queries.rs +++ b/crates/application/src/queries.rs @@ -85,4 +85,17 @@ pub struct GetMoviesQuery { pub limit: Option, pub offset: Option, pub search: Option, + pub genre: Option, + pub language: Option, +} + +pub struct GetWatchlistQuery { + pub user_id: Uuid, + pub limit: Option, + pub offset: Option, +} + +pub struct IsOnWatchlistQuery { + pub user_id: Uuid, + pub movie_id: Uuid, } diff --git a/crates/application/src/tests/movie_resolver.rs b/crates/application/src/tests/movie_resolver.rs index 1f64711..339d5e4 100644 --- a/crates/application/src/tests/movie_resolver.rs +++ b/crates/application/src/tests/movie_resolver.rs @@ -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) -> LogReviewCommand { - LogReviewCommand { +fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option) -> 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, DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, 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, DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, 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, DomainError> { panic!("unexpected") } + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, 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); diff --git a/crates/application/src/tests/worker.rs b/crates/application/src/tests/worker.rs index 2da1f5e..f5d450b 100644 --- a/crates/application/src/tests/worker.rs +++ b/crates/application/src/tests/worker.rs @@ -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(()) diff --git a/crates/application/src/use_cases/add_to_watchlist.rs b/crates/application/src/use_cases/add_to_watchlist.rs new file mode 100644 index 0000000..0a9177f --- /dev/null +++ b/crates/application/src/use_cases/add_to_watchlist.rs @@ -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(()) +} diff --git a/crates/application/src/use_cases/apply_import_mapping.rs b/crates/application/src/use_cases/apply_import_mapping.rs index 73321d4..a58c9a0 100644 --- a/crates/application/src/use_cases/apply_import_mapping.rs +++ b/crates/application/src/use_cases/apply_import_mapping.rs @@ -36,13 +36,11 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result } async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result { - 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::().ok().and_then(|y| ReleaseYear::new(y).ok()); diff --git a/crates/application/src/use_cases/execute_import.rs b/crates/application/src/use_cases/execute_import.rs index 7cd754f..3ed98bb 100644 --- a/crates/application/src/use_cases/execute_import.rs +++ b/crates/application/src/use_cases/execute_import.rs @@ -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, + pub profile: Option, } 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 }) } diff --git a/crates/application/src/use_cases/get_movies.rs b/crates/application/src/use_cases/get_movies.rs index 0c6242b..163995e 100644 --- a/crates/application/src/use_cases/get_movies.rs +++ b/crates/application/src/use_cases/get_movies.rs @@ -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, DomainError> { +pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result, 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 } diff --git a/crates/application/src/use_cases/get_watchlist.rs b/crates/application/src/use_cases/get_watchlist.rs new file mode 100644 index 0000000..be2ecae --- /dev/null +++ b/crates/application/src/use_cases/get_watchlist.rs @@ -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, 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 +} diff --git a/crates/application/src/use_cases/is_on_watchlist.rs b/crates/application/src/use_cases/is_on_watchlist.rs new file mode 100644 index 0000000..fae8dea --- /dev/null +++ b/crates/application/src/use_cases/is_on_watchlist.rs @@ -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 { + 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 +} diff --git a/crates/application/src/use_cases/log_review.rs b/crates/application/src/use_cases/log_review.rs index e566ab5..14b3dd4 100644 --- a/crates/application/src/use_cases/log_review.rs +++ b/crates/application/src/use_cases/log_review.rs @@ -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 { diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index de4e579..05f9156 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -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; diff --git a/crates/application/src/use_cases/remove_from_watchlist.rs b/crates/application/src/use_cases/remove_from_watchlist.rs new file mode 100644 index 0000000..e3770e6 --- /dev/null +++ b/crates/application/src/use_cases/remove_from_watchlist.rs @@ -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(()) +} diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index b1beb28..48e4440 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -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, + added_at: chrono::NaiveDateTime, + }, + WatchlistEntryRemoved { + user_id: UserId, + movie_id: MovieId, + }, } #[async_trait] diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index d203c56..ced77b5 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -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, } +#[derive(Clone, Debug, Default)] +pub struct MovieFilter { + pub search: Option, + pub genre: Option, + pub language: Option, +} + +#[derive(Clone, Debug)] +pub struct MovieSummary { + pub movie: Movie, + pub genres: Vec, + pub runtime_minutes: Option, + pub original_language: Option, + pub overview: Option, + pub collection_name: Option, +} + #[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, diff --git a/crates/domain/src/models/remote_watchlist.rs b/crates/domain/src/models/remote_watchlist.rs new file mode 100644 index 0000000..a471522 --- /dev/null +++ b/crates/domain/src/models/remote_watchlist.rs @@ -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, + pub poster_url: Option, + pub added_at: DateTime, +} diff --git a/crates/domain/src/models/watchlist.rs b/crates/domain/src/models/watchlist.rs new file mode 100644 index 0000000..9f842d2 --- /dev/null +++ b/crates/domain/src/models/watchlist.rs @@ -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, +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index ea7b9fe..e907260 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -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, DomainError>; + filter: &MovieFilter, + ) -> Result, 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; + + async fn get_for_user( + &self, + user_id: &UserId, + page: &collections::PageParams, + ) -> Result, DomainError>; + + async fn contains( + &self, + user_id: &UserId, + movie_id: &MovieId, + ) -> Result; +} + +#[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, 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, DomainError>; +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index 59d42fb..e651df0 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -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 diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index cff472d..b835c2c 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -9,7 +9,7 @@ sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"] postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"] nats = ["dep:nats"] # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working -federation = [] +federation = ["application/federation"] sqlite-federation = [ "sqlite", "dep:sqlite-federation", diff --git a/crates/presentation/src/forms.rs b/crates/presentation/src/forms.rs index 3cfb796..3753df4 100644 --- a/crates/presentation/src/forms.rs +++ b/crates/presentation/src/forms.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use serde::Deserialize; use uuid::Uuid; -use application::{commands::LogReviewCommand, queries::GetDiaryQuery}; +use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery}; use domain::{errors::DomainError, models::SortDirection}; use api_types::{DiaryQueryParams, LogReviewRequest}; @@ -124,6 +124,25 @@ pub struct ActorUrlForm { pub csrf_token: String, } +#[derive(serde::Deserialize)] +pub struct WatchlistAddForm { + pub movie_id: Option, + pub query: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + pub year: Option, + #[serde(rename = "_csrf", default)] + pub csrf_token: String, + #[serde(default)] + pub redirect_after: Option, +} + +#[derive(serde::Deserialize, Default)] +pub struct WatchlistQuery { + pub limit: Option, + pub offset: Option, + pub error: Option, +} + #[derive(Deserialize, Default)] pub struct ProfileQueryParams { pub view: Option, @@ -206,14 +225,17 @@ impl TryFrom for LogReviewData { impl LogReviewData { pub fn into_command(self, user_id: Uuid) -> LogReviewCommand { LogReviewCommand { - external_metadata_id: self.external_metadata_id, - manual_title: self.manual_title, - manual_release_year: self.manual_release_year, - manual_director: self.manual_director, + user_id, + input: MovieInput { + movie_id: None, + external_metadata_id: self.external_metadata_id, + manual_title: self.manual_title, + manual_release_year: self.manual_release_year, + manual_director: self.manual_director, + }, rating: self.rating, comment: self.comment, watched_at: self.watched_at, - user_id, } } } diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 4860e98..788ae57 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -10,11 +10,13 @@ use std::str::FromStr; use application::{ commands::{ - DeleteReviewCommand, RegisterCommand, SyncPosterCommand, + DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand, + AddToWatchlistCommand, RemoveFromWatchlistCommand, }, queries::{ ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery, + GetWatchlistQuery, IsOnWatchlistQuery, }, use_cases::{ delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, @@ -22,11 +24,12 @@ use application::{ get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, register as register_uc, sync_poster, update_profile, search as search_uc, get_person, get_person_credits, + add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist, }, }; use domain::{ errors::DomainError, - models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams}, + models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams}, services::review_history::Trend, value_objects::{MovieId, UserId}, }; @@ -44,6 +47,7 @@ use api_types::{ MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, + WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest, }; use api_types::search::{ CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto, @@ -96,12 +100,14 @@ pub async fn list_movies( limit: params.limit, offset: params.offset, search: params.search, + genre: params.genre, + language: params.language, }, ) .await?; Ok(Json(MoviesResponse { - items: page.items.iter().map(movie_to_dto).collect(), + items: page.items.iter().map(summary_to_dto).collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, @@ -438,12 +444,11 @@ pub async fn update_profile_handler( } "avatar" => { let content_type = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = content_type; } - } } _ => {} } @@ -476,6 +481,26 @@ fn movie_to_dto(movie: &Movie) -> MovieDto { release_year: movie.release_year().value(), director: movie.director().map(|d| d.to_string()), poster_path: movie.poster_path().map(|p| p.value().to_string()), + genres: vec![], + runtime_minutes: None, + original_language: None, + overview: None, + collection_name: None, + } +} + +fn summary_to_dto(summary: &MovieSummary) -> MovieDto { + MovieDto { + id: summary.movie.id().value(), + title: summary.movie.title().value().to_string(), + release_year: summary.movie.release_year().value(), + director: summary.movie.director().map(|d| d.to_string()), + poster_path: summary.movie.poster_path().map(|p| p.value().to_string()), + genres: summary.genres.clone(), + runtime_minutes: summary.runtime_minutes, + original_language: summary.original_language.clone(), + overview: summary.overview.clone(), + collection_name: summary.collection_name.clone(), } } @@ -1233,3 +1258,119 @@ pub async fn get_person_credits_handler( } } } + +#[utoipa::path( + get, path = "/api/v1/watchlist", + params( + ("limit" = Option, Query, description = "Max results"), + ("offset" = Option, Query, description = "Offset"), + ), + responses( + (status = 200, body = WatchlistResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_handler( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> Result, ApiError> { + let page = get_watchlist::execute( + &state.app_ctx, + GetWatchlistQuery { + user_id: user.0.value(), + limit: params.limit, + offset: params.offset, + }, + ) + .await?; + + Ok(Json(WatchlistResponse { + items: page.items.into_iter().map(|w| WatchlistEntryDto { + id: w.entry.id.value(), + movie: movie_to_dto(&w.movie), + added_at: w.entry.added_at.to_string(), + }).collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + post, path = "/api/v1/watchlist", + request_body = AddToWatchlistRequest, + responses( + (status = 201, description = "Added to watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_watchlist_add( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + add_to_watchlist::execute( + &state.app_ctx, + AddToWatchlistCommand { + user_id: user.0.value(), + input: MovieInput { + movie_id: Some(req.movie_id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + }, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Removed from watchlist"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not on watchlist"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_watchlist_entry( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + remove_from_watchlist::execute( + &state.app_ctx, + RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id }, + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, path = "/api/v1/watchlist/{movie_id}", + params(("movie_id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = WatchlistStatusResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_watchlist_status( + State(state): State, + user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result, ApiError> { + let on_watchlist = is_on_watchlist::execute( + &state.app_ctx, + IsOnWatchlistQuery { user_id: user.0.value(), movie_id }, + ) + .await?; + Ok(Json(WatchlistStatusResponse { on_watchlist })) +} diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index be16ca8..ba0b857 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -15,15 +15,17 @@ use application::ports::{ FollowersPageData, FollowingPageData, }; use application::{ - commands::{DeleteReviewCommand, RegisterCommand}, - queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery}, + commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand}, + queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, LoginQuery}, ports::{ HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, - ProfileSettingsPageData, RegisterPageData, RemoteActorView, + ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry, + WatchlistPageData, }, use_cases::{ - delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, - login as login_uc, register as register_uc, update_profile, + add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page, + get_watchlist, log_review, login as login_uc, register as register_uc, + remove_from_watchlist, update_profile, }, }; use domain::models::ExportFormat; @@ -383,12 +385,11 @@ pub async fn get_activity_feed( let mut local_ids = vec![uid.value()]; let mut remote_urls = Vec::new(); for url in urls { - if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) { - if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { + if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) + && let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { local_ids.push(parsed_id); continue; } - } remote_urls.push(url); } Some(domain::ports::FollowingFilter { @@ -953,7 +954,7 @@ pub async fn get_movie_detail( Query(params): Query, Extension(csrf): Extension, ) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id, csrf.0).await; + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; let limit = params.limit.unwrap_or(20); let offset = params.offset.unwrap_or(0); @@ -973,10 +974,19 @@ pub async fn get_movie_detail( let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1); let has_more = result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32; + let on_watchlist = match &user_id { + Some(uid) => state.app_ctx.watchlist_repository + .contains(uid, &domain::value_objects::MovieId::from_uuid(movie_id)) + .await + .unwrap_or(false), + None => false, + }; let data = MovieDetailPageData { ctx, movie: result.movie, stats: result.stats, + profile: result.profile, + on_watchlist, current_offset: result.reviews.offset, has_more, limit: result.reviews.limit, @@ -994,6 +1004,206 @@ pub async fn get_movie_detail( } } +pub async fn get_watchlist_page( + OptionalCookieUser(viewer_id): OptionalCookieUser, + State(state): State, + Path(owner_id): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); + + // Try local user first + let local_user = state.app_ctx.user_repository + .find_by_id(&domain::value_objects::UserId::from_uuid(owner_id)) + .await + .ok() + .flatten(); + + let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() { + match get_watchlist::execute( + &state.app_ctx, + GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) }, + ).await { + Err(e) => { + tracing::error!("watchlist error: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + Ok(entries) => { + let has_more = entries.offset + entries.limit < entries.total_count as u32; + let display: Vec = entries.items.iter().map(|w| { + let remove_url = if is_owner { + Some(format!("/watchlist/{}/remove", w.movie.id().value())) + } else { + None + }; + WatchlistDisplayEntry { + poster_url: w.movie.poster_path() + .map(|p| format!("/images/{}", p.value())), + movie_title: w.movie.title().value().to_string(), + release_year: w.movie.release_year().value(), + movie_url: Some(format!("/movies/{}", w.movie.id().value())), + added_at: w.entry.added_at.format("%b %-d, %Y").to_string(), + remove_url, + } + }).collect(); + (display, has_more, entries.offset, entries.limit) + } + } + } else { + #[cfg(feature = "federation")] + { + let remote_entries = state.app_ctx.remote_watchlist_repository + .get_by_derived_uuid(owner_id) + .await + .unwrap_or_default(); + let display: Vec = remote_entries.into_iter().map(|e| { + WatchlistDisplayEntry { + poster_url: e.poster_url, + movie_title: e.movie_title, + release_year: e.release_year, + movie_url: None, + added_at: e.added_at.format("%b %-d, %Y").to_string(), + remove_url: None, + } + }).collect(); + let len = display.len() as u32; + (display, false, 0u32, len) + } + #[cfg(not(feature = "federation"))] + { + (vec![], false, 0u32, 0u32) + } + }; + + let data = WatchlistPageData { + ctx, + owner_id, + display_entries, + current_offset, + has_more, + limit: page_limit, + is_owner, + error: params.error, + }; + match state.html_renderer.render_watchlist_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => { + tracing::error!("watchlist template error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn post_watchlist_add( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + + let redirect_base = form + .redirect_after + .as_deref() + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or("/") + .to_string(); + + let input = if let Some(id) = form.movie_id { + MovieInput { + movie_id: Some(id), + external_metadata_id: None, + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + let query = form.query.as_deref().unwrap_or("").trim().to_string(); + let is_external_id = query.starts_with("tmdb:") + || (query.starts_with("tt") + && query.len() > 2 + && query[2..].chars().all(|c| c.is_ascii_digit())); + if is_external_id { + MovieInput { + movie_id: None, + external_metadata_id: Some(query), + manual_title: None, + manual_release_year: None, + manual_director: None, + } + } else { + MovieInput { + movie_id: None, + external_metadata_id: None, + manual_title: if query.is_empty() { None } else { Some(query) }, + manual_release_year: form.year, + manual_director: None, + } + } + }; + + match add_to_watchlist::execute( + &state.app_ctx, + AddToWatchlistCommand { + user_id: user_id.value(), + input, + }, + ) + .await + { + Ok(()) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(), + Err(DomainError::ValidationError(msg)) => { + let sep = if redirect_base.contains('?') { '&' } else { '?' }; + let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg)); + Redirect::to(&url).into_response() + } + Err(e) => { + tracing::error!("watchlist add error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn post_watchlist_remove( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(movie_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match remove_from_watchlist::execute( + &state.app_ctx, + RemoveFromWatchlistCommand { + user_id: user_id.value(), + movie_id, + }, + ) + .await + { + Ok(()) | Err(DomainError::NotFound(_)) => { + let redirect_url = form + .redirect_after + .filter(|u| u.starts_with('/') && !u.starts_with("//")) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(e) => { + tracing::error!("watchlist remove error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + #[derive(serde::Deserialize, Default)] pub struct SavedQuery { pub saved: Option, @@ -1219,12 +1429,11 @@ pub async fn post_profile_settings( } "avatar" => { let content_type = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await { - if !bytes.is_empty() { + if let Ok(bytes) = field.bytes().await + && !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = content_type; } - } } _ => {} } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index ed1dd81..947f40d 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -15,8 +15,6 @@ use presentation::{openapi, routes, state::AppState}; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository}; -#[cfg(feature = "sqlite")] -use sqlite_search; #[cfg(feature = "postgres")] use postgres_search; @@ -54,21 +52,21 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let poster_fetcher = poster_fetcher::create()?; let image_storage = image_storage::create()?; - let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, person_command, person_query, search_command, search_port, db_pool) = + let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, watchlist_repository, person_command, person_query, search_command, search_port, db_pool) = match backend.as_str() { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp) = postgres::wire(&database_url).await?; + let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?; let (pc, pq) = postgres::create_person_adapter(pool.clone()); let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); - (m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Postgres(pool)) + (m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Postgres(pool)) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u, is, ip, mp) = sqlite::wire(&database_url).await?; + let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?; let (pc, pq) = sqlite::create_person_adapter(pool.clone()); let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); - (m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Sqlite(pool)) + (m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Sqlite(pool)) } #[cfg(not(feature = "sqlite"))] _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), @@ -78,8 +76,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let event_bus = EventBusBackend::from_env()?; #[cfg(feature = "federation")] - let (event_publisher_arc, ap_router, ap_service, social_query) = { - let (federation_repo, social_query_arc, review_store) = match &db_pool { + let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = { + let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool { #[cfg(feature = "postgres-federation")] DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), #[cfg(feature = "sqlite-federation")] @@ -91,6 +89,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let ap = activitypub::wire( federation_repo, review_store, + remote_watchlist_repo.clone(), + Arc::clone(&watchlist_repository), Arc::clone(&user_repository), Arc::clone(&movie_repository), Arc::clone(&review_repository), @@ -123,7 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { nats::create_publisher(cfg).await? } }; - (ep, ap_router, ap_service_arc, social_query_arc) + (ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo) }; #[cfg(not(feature = "federation"))] @@ -171,6 +171,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { import_session_repository: import_session_repository as Arc, import_profile_repository: import_profile_repository as Arc, movie_profile_repository, + watchlist_repository, + #[cfg(feature = "federation")] + remote_watchlist_repository: remote_watchlist_repo, person_command, person_query, search_port, diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs index 978b2eb..a2aa719 100644 --- a/crates/presentation/src/openapi/mod.rs +++ b/crates/presentation/src/openapi/mod.rs @@ -5,6 +5,7 @@ mod movies; mod search; mod social; mod users; +mod watchlist; use axum::Router; use utoipa::{ @@ -38,6 +39,7 @@ fn build() -> utoipa::openapi::OpenApi { api.merge(users::UsersDoc::openapi()); api.merge(import::ImportDoc::openapi()); api.merge(search::SearchDoc::openapi()); + api.merge(watchlist::WatchlistDoc::openapi()); #[cfg(feature = "federation")] api.merge(social::SocialDoc::openapi()); SecurityAddon.modify(&mut api); diff --git a/crates/presentation/src/openapi/watchlist.rs b/crates/presentation/src/openapi/watchlist.rs new file mode 100644 index 0000000..ba6bd8d --- /dev/null +++ b/crates/presentation/src/openapi/watchlist.rs @@ -0,0 +1,19 @@ +use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::api::get_watchlist_handler, + crate::handlers::api::post_watchlist_add, + crate::handlers::api::delete_watchlist_entry, + crate::handlers::api::get_watchlist_status, + ), + components(schemas( + WatchlistResponse, + WatchlistEntryDto, + AddToWatchlistRequest, + WatchlistStatusResponse, + )) +)] +pub struct WatchlistDoc; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 2610151..3ae634d 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -107,7 +107,19 @@ fn html_routes(rate_limit: u64) -> Router { routing::get(handlers::html::get_profile_settings) .post(handlers::html::post_profile_settings), ) - .route("/tags/{tag}", routing::get(handlers::html::get_tag)); + .route("/tags/{tag}", routing::get(handlers::html::get_tag)) + .route( + "/users/{id}/watchlist", + routing::get(handlers::html::get_watchlist_page), + ) + .route( + "/watchlist/add", + routing::post(handlers::html::post_watchlist_add), + ) + .route( + "/watchlist/{movie_id}/remove", + routing::post(handlers::html::post_watchlist_remove), + ); #[cfg(feature = "federation")] let base = base.merge(federation_html_routes()); @@ -213,7 +225,17 @@ fn api_routes(rate_limit: u64) -> Router { .route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler)) .route("/search", routing::get(handlers::api::get_search)) .route("/people/{id}", routing::get(handlers::api::get_person_handler)) - .route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler)); + .route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler)) + .route( + "/watchlist", + routing::get(handlers::api::get_watchlist_handler) + .post(handlers::api::post_watchlist_add), + ) + .route( + "/watchlist/{movie_id}", + routing::get(handlers::api::get_watchlist_status) + .delete(handlers::api::delete_watchlist_entry), + ); #[cfg(feature = "federation")] let base = base.merge(federation_api_routes()); diff --git a/crates/presentation/src/tests/api_handlers.rs b/crates/presentation/src/tests/api_handlers.rs index b6fd08d..b662e3f 100644 --- a/crates/presentation/src/tests/api_handlers.rs +++ b/crates/presentation/src/tests/api_handlers.rs @@ -143,3 +143,48 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +// --- Watchlist endpoint tests --- + +#[tokio::test] +async fn get_watchlist_requires_auth() { + let state = make_test_state(Arc::new(Panic)); + let app = Router::new() + .route("/api/v1/watchlist", get(crate::handlers::api::get_watchlist_handler)) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/watchlist") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn get_watchlist_status_requires_auth() { + let state = make_test_state(Arc::new(Panic)); + let app = Router::new() + .route( + "/api/v1/watchlist/{movie_id}", + get(crate::handlers::api::get_watchlist_status), + ) + .with_state(state); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 450f30e..0167e48 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -19,7 +19,7 @@ use domain::{ ports::{ AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, - StatsRepository, UserRepository, + StatsRepository, UserRepository, WatchlistRepository, PersonCommand, PersonQuery, SearchPort, SearchCommand, }, value_objects::{ @@ -58,7 +58,7 @@ impl MovieRepository for Panic { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() } - async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result, DomainError> { + async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result, DomainError> { panic!() } } @@ -242,6 +242,14 @@ impl domain::ports::ImportProfileRepository for Panic { async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] +impl WatchlistRepository for Panic { + async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } + async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() } + async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } + async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!() } + async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } +} +#[async_trait::async_trait] impl domain::ports::MovieProfileRepository for Panic { async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result, DomainError> { Ok(None) } @@ -335,6 +343,7 @@ impl crate::ports::HtmlRenderer for Panic { fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result { panic!() } fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result { panic!() } + fn render_watchlist_page(&self, _: application::ports::WatchlistPageData) -> Result { panic!() } } impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { @@ -373,6 +382,15 @@ impl SearchCommand for Panic { async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() } async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() } } +#[cfg(feature = "federation")] +#[async_trait::async_trait] +impl domain::ports::RemoteWatchlistRepository for Panic { + async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) } + async fn get_by_actor_url(&self, _: &str) -> Result, DomainError> { Ok(vec![]) } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } + async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result, DomainError> { Ok(vec![]) } +} // --- Single state factory — only auth_service varies --- @@ -395,6 +413,9 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS import_session_repository: Arc::clone(&repo) as _, import_profile_repository: Arc::clone(&repo) as _, movie_profile_repository: Arc::clone(&repo) as _, + watchlist_repository: Arc::clone(&repo) as _, + #[cfg(feature = "federation")] + remote_watchlist_repository: Arc::clone(&repo) as _, person_command: Arc::clone(&repo) as _, person_query: Arc::clone(&repo) as _, search_port: Arc::clone(&repo) as _, diff --git a/crates/presentation/src/tests/mod.rs b/crates/presentation/src/tests/mod.rs index 3b0a423..6a0034a 100644 --- a/crates/presentation/src/tests/mod.rs +++ b/crates/presentation/src/tests/mod.rs @@ -1,45 +1,10 @@ -// Re-export imports needed by subtest modules -pub use application::{config::AppConfig, context::AppContext}; -pub use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, - routing::get, -}; -pub use domain::{ - errors::DomainError, - events::DomainEvent, - models::{ - DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, - UserTrends, - collections::{PageParams, Paginated}, - PersonId, EntityType, IndexableDocument, Person, PersonCredits, - SearchQuery, SearchResults, - }, - ports::{ - AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, - MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, - StatsRepository, UserRepository, - PersonCommand, PersonQuery, SearchPort, SearchCommand, - }, - value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, - ReleaseYear, ReviewId, UserId, - }, -}; -pub use std::sync::Arc; -pub use tower::ServiceExt; - // API types for tests -pub use api_types::{ - LoginRequest, LogReviewRequest, DiaryQueryParams, -}; pub use crate::{ extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser}, forms::{LogReviewData, LogReviewForm, to_diary_query}, - state::AppState, }; +pub use api_types::{DiaryQueryParams, LogReviewRequest}; +mod api_handlers; mod extractors; mod forms; -mod api_handlers; diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index bc8f3ab..4c6d364 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -164,6 +164,16 @@ impl domain::ports::ImportProfileRepository for PanicImportProfile { async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } } +struct PanicWatchlist; +#[async_trait] +impl domain::ports::WatchlistRepository for PanicWatchlist { + async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } + async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() } + async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } + async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result, DomainError> { panic!() } + async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result { Ok(false) } +} + struct PanicPersonCommand; #[async_trait] impl PersonCommand for PanicPersonCommand { @@ -194,6 +204,18 @@ impl SearchCommand for PanicSearchCommand { #[cfg(feature = "federation")] struct PanicSocialQuery; + +#[cfg(feature = "federation")] +struct PanicRemoteWatchlist; +#[cfg(feature = "federation")] +#[async_trait::async_trait] +impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { + async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) } + async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) } + async fn get_by_actor_url(&self, _: &str) -> Result, DomainError> { Ok(vec![]) } + async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } + async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result, DomainError> { Ok(vec![]) } +} #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for PanicSocialQuery { @@ -236,6 +258,9 @@ async fn test_app() -> Router { import_session_repository: Arc::new(PanicImportSession), import_profile_repository: Arc::new(PanicImportProfile), movie_profile_repository: Arc::new(PanicMovieProfile), + watchlist_repository: Arc::new(PanicWatchlist), + #[cfg(feature = "federation")] + remote_watchlist_repository: Arc::new(PanicRemoteWatchlist), person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(PanicPersonQuery), search_port: Arc::new(PanicSearchPort), diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 281b8cc..a6aeb5d 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -644,23 +644,21 @@ pub fn update(app: &mut App, action: Action) -> Vec { } Action::ScrollUp => { - if let Screen::Main(m) = &mut app.screen { - if m.diary.selected > 0 { + if let Screen::Main(m) = &mut app.screen + && m.diary.selected > 0 { m.diary.selected -= 1; m.diary.history = None; } - } vec![] } Action::OpenHistory => { - if let Screen::Main(m) = &mut app.screen { - if let Some(entry) = m.diary.entries.get(m.diary.selected) { + if let Screen::Main(m) = &mut app.screen + && let Some(entry) = m.diary.entries.get(m.diary.selected) { let movie_id = entry.movie.id; app.loading = true; return vec![Command::LoadHistory { movie_id }]; } - } vec![] } @@ -676,13 +674,12 @@ pub fn update(app: &mut App, action: Action) -> Vec { } Action::LoadPrev => { - if let Screen::Main(m) = &mut app.screen { - if m.diary.offset > 0 { + if let Screen::Main(m) = &mut app.screen + && m.diary.offset > 0 { let prev = m.diary.offset.saturating_sub(20); m.diary.offset = prev; return vec![Command::LoadDiary { offset: prev }]; } - } vec![] } @@ -732,20 +729,18 @@ pub fn update(app: &mut App, action: Action) -> Vec { } Action::DeleteInit => { - if let Screen::Main(m) = &mut app.screen { - if let Some(entry) = m.diary.entries.get(m.diary.selected) { + if let Screen::Main(m) = &mut app.screen + && let Some(entry) = m.diary.entries.get(m.diary.selected) { m.diary.delete_pending = Some(entry.review.id); } - } vec![] } Action::DeleteConfirm => { - if let Screen::Main(m) = &mut app.screen { - if let Some(review_id) = m.diary.delete_pending.take() { + if let Screen::Main(m) = &mut app.screen + && let Some(review_id) = m.diary.delete_pending.take() { return vec![Command::DeleteReview(review_id)]; } - } vec![] } @@ -782,26 +777,24 @@ pub fn update(app: &mut App, action: Action) -> Vec { // ── Add Review ──────────────────────────────────────────────────────── Action::RatingUp => { - if let Screen::Main(m) = &mut app.screen { - if m.add_review.rating < 5 { + if let Screen::Main(m) = &mut app.screen + && m.add_review.rating < 5 { m.add_review.rating += 1; } - } vec![] } Action::RatingDown => { - if let Screen::Main(m) = &mut app.screen { - if m.add_review.rating > 0 { + if let Screen::Main(m) = &mut app.screen + && m.add_review.rating > 0 { m.add_review.rating -= 1; } - } vec![] } Action::ReviewSubmit => { - if let Screen::Main(m) = &app.screen { - if m.tab == Tab::AddReview { + if let Screen::Main(m) = &app.screen + && m.tab == Tab::AddReview { let f = &m.add_review; let has_ext = !f.external_id.is_empty(); let has_title = !f.title.is_empty(); @@ -851,7 +844,6 @@ pub fn update(app: &mut App, action: Action) -> Vec { app.loading = true; return vec![Command::CreateReview(req)]; } - } vec![] } @@ -878,8 +870,8 @@ pub fn update(app: &mut App, action: Action) -> Vec { // ── Bulk Import ─────────────────────────────────────────────────────── Action::BulkParseFile => { - if let Screen::Main(m) = &mut app.screen { - if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath { + if let Screen::Main(m) = &mut app.screen + && m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath { let path = m.bulk_import.file_path.trim().to_string(); match std::fs::read_to_string(&path) { Ok(content) => { @@ -894,13 +886,12 @@ pub fn update(app: &mut App, action: Action) -> Vec { } } } - } vec![] } Action::BulkImportAll => { - if let Screen::Main(m) = &mut app.screen { - if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { + if let Screen::Main(m) = &mut app.screen + && m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { let valid: Vec = m .bulk_import .parsed @@ -919,7 +910,6 @@ pub fn update(app: &mut App, action: Action) -> Vec { m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; return vec![Command::ImportNext(0)]; } - } vec![] } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9b723a2..d18afbb 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -41,8 +41,8 @@ async fn run() -> anyhow::Result<()> { let mut terminal = ratatui::init(); // If we start directly in Main (saved token), trigger an initial diary load - if matches!(app.screen, Screen::Main(_)) { - if let Some(token) = &saved_token { + if matches!(app.screen, Screen::Main(_)) + && let Some(token) = &saved_token { let c = client.clone(); let t = token.clone(); let tx2 = tx.clone(); @@ -57,15 +57,14 @@ async fn run() -> anyhow::Result<()> { let _ = tx2.send(action).await; }); } - } let result = async { loop { terminal.draw(|f| tui::ui::render(f, &app))?; // Poll keyboard — non-blocking with short timeout - if event::poll(Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { + if event::poll(Duration::from_millis(50))? + && let Event::Key(key) = event::read()? { if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; } @@ -79,7 +78,6 @@ async fn run() -> anyhow::Result<()> { } } } - } // Drain async results while let Ok(action) = rx.try_recv() { diff --git a/crates/tui/src/tests/app.rs b/crates/tui/src/tests/app.rs index f8bc26b..be6c80f 100644 --- a/crates/tui/src/tests/app.rs +++ b/crates/tui/src/tests/app.rs @@ -43,6 +43,11 @@ fn diary_entry() -> DiaryEntryDto { release_year: 1999, director: None, poster_path: None, + genres: vec![], + runtime_minutes: None, + original_language: None, + overview: None, + collection_name: None, }, review: ReviewDto { id: Uuid::new_v4(), diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 63134a1..0ce3938 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -4,11 +4,11 @@ version = "0.1.0" edition = "2024" [features] -default = ["sqlite"] +default = ["sqlite", "sqlite-federation"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"] postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"] nats = ["dep:nats"] -federation = [] +federation = ["application/federation"] sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"] diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index f6fefc9..dd72047 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -4,7 +4,7 @@ use anyhow::Context; use domain::ports::{ DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, - ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository, }; pub enum DbPool { @@ -23,6 +23,7 @@ pub struct Repos { pub import_session: Arc, pub import_profile: Arc, pub movie_profile: Arc, + pub watchlist: Arc, pub image_ref_command: Arc, pub image_ref_query: Arc, pub person_command: Arc, @@ -35,26 +36,26 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos match backend { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u, is, ip, mp) = + let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(database_url).await.context("PostgreSQL connection failed")?; let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone()); let (person_command, person_query) = postgres::create_person_adapter(pool.clone()); let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone()); Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, - import_session: is, import_profile: ip, movie_profile: mp, + import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, image_ref_command, image_ref_query, person_command, person_query, search_command, search_port }, DbPool::Postgres(pool))) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u, is, ip, mp) = + let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(database_url).await.context("SQLite connection failed")?; let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone()); let (person_command, person_query) = sqlite::create_person_adapter(pool.clone()); let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, - import_session: is, import_profile: ip, movie_profile: mp, + import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, image_ref_command, image_ref_query, person_command, person_query, search_command, search_port }, DbPool::Sqlite(pool))) diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index 923fa9a..09d9ce6 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +#[cfg(feature = "nats")] use anyhow::Context; use domain::ports::{EventConsumer, EventPublisher}; diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index c45dd5e..6dd1a9e 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -48,6 +48,14 @@ async fn main() -> anyhow::Result<()> { app_config.base_url.clone(), app_config.allow_registration, ); + // Wire federation repos early to get remote_watchlist_repo for AppContext. + #[cfg(feature = "federation")] + let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) = match &db_pool { + #[cfg(feature = "sqlite-federation")] + db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), + #[cfg(feature = "postgres-federation")] + db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), + }; let ctx = AppContext { movie_repository: repos.movie, @@ -66,6 +74,9 @@ async fn main() -> anyhow::Result<()> { import_session_repository: repos.import_session, import_profile_repository: repos.import_profile, movie_profile_repository: repos.movie_profile, + watchlist_repository: repos.watchlist, + #[cfg(feature = "federation")] + remote_watchlist_repository: fed_remote_watchlist_repo.clone(), person_command: Arc::clone(&person_command), person_query: Arc::clone(&person_query), search_port: Arc::clone(&search_port), @@ -155,16 +166,11 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "federation")] { - let (federation_repo, _social_query, review_store) = match &db_pool { - #[cfg(feature = "sqlite-federation")] - db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), - #[cfg(feature = "postgres-federation")] - db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), - }; - let ap = activitypub::wire( - federation_repo, - review_store, + fed_federation_repo, + fed_review_store, + fed_remote_watchlist_repo, + Arc::clone(&ctx.watchlist_repository), fed_user_repo, fed_movie_repo, fed_review_repo, diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..45bfb4f --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Movies Diary", + "short_name": "Movies", + "description": "Track what you watch, rate and review films.", + "start_url": "/", + "display": "standalone", + "background_color": "#111111", + "theme_color": "#e5c034", + "icons": [ + { + "src": "/static/logo.webp", + "sizes": "any", + "type": "image/webp", + "purpose": "any" + } + ] +} diff --git a/static/person-placeholder.svg b/static/person-placeholder.svg new file mode 100644 index 0000000..d6f09dc --- /dev/null +++ b/static/person-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/style.css b/static/style.css index c93ed9b..1d400e2 100644 --- a/static/style.css +++ b/static/style.css @@ -389,7 +389,8 @@ form button[type="submit"]:hover { } /* Errors */ -.error { +.error, +.form-error { color: #e05050; background: rgba(220, 50, 50, 0.1); padding: 8px 12px; @@ -1037,6 +1038,102 @@ form button[type="submit"]:hover { .hist-bar { background: oklch(85.2% 0.199 91.936); border-radius: 2px; height: 100%; min-width: 1px; } .hist-count { color: rgba(255, 255, 255, 0.45); width: 1.5rem; } +/* ── Movie detail — profile sections ──────────────────────────────────────── */ + +.movie-meta { + font-size: 0.82em; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +.genre-pills { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.35rem; + margin-bottom: 0.35rem; +} + +.genre-pill { + font-size: 0.72em; + padding: 0.15rem 0.55rem; + border-radius: 10px; + background: rgba(229, 192, 52, 0.12); + border: 1px solid rgba(229, 192, 52, 0.3); + color: oklch(85.2% 0.199 91.936); +} + +.movie-tagline { + font-size: 0.85em; + font-style: italic; + color: var(--text-muted); + margin-top: 0.3rem; +} + +.movie-overview { + font-size: 0.88em; + color: var(--text-muted); + line-height: 1.6; + margin-bottom: 1.25rem; +} + +.cast-strip { + display: flex; + gap: 10px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 1.25rem; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,.15) transparent; +} + +.cast-card { + flex-shrink: 0; + width: 72px; +} + +.cast-card img { + width: 72px; + height: 96px; + object-fit: cover; + border-radius: 6px; + display: block; + background: #2a2a2a; +} + +.cast-name { + font-size: 0.7em; + font-weight: 600; + margin-top: 4px; + line-height: 1.3; + color: var(--text); +} + +.cast-char { + font-size: 0.65em; + color: var(--text-muted); + font-style: italic; + margin-top: 1px; +} + +.crew-list { + list-style: none; + padding: 0; + margin: 0 0 1.25rem; + font-size: 0.85em; +} + +.crew-list li { + padding: 0.2rem 0; + color: var(--text-muted); +} + +.crew-role { + color: var(--text-light); + margin-right: 0.4rem; + font-size: 0.9em; +} + .feed-section-label { font-size: 0.7rem; text-transform: uppercase;