feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation

This commit is contained in:
2026-05-13 00:23:45 +02:00
parent 2fd8734d23
commit 53df90ab1f
84 changed files with 2755 additions and 398 deletions

View File

@@ -200,7 +200,7 @@ pub struct UndoActivity {
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
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<Self::DataType>) -> 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<DbActor>,
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<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> 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),
}

View File

@@ -34,11 +34,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
.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<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
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<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
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?;

View File

@@ -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<ReviewObjectHandler>,
pub watchlist: Arc<WatchlistObjectHandler>,
}
#[async_trait]
impl ApObjectHandler for CompositeObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
self.review.get_local_objects_for_user(user_id).await
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
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<u64> {
self.review.count_local_posts().await
}
}

View File

@@ -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<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
watchlist_repository: Arc<dyn WatchlistRepository>,
base_url: String,
}
@@ -25,12 +26,14 @@ impl ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
watchlist_repository: Arc<dyn WatchlistRepository>,
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<String>,
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::<chrono::Utc>::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(())
}
}

View File

@@ -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<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
watchlist_repo: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
) -> anyhow::Result<ActivityPubWire> {
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<dyn domain::ports::EventHandler>;

View File

@@ -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<chrono::Utc>,
pub(crate) movie_title: String,
#[serde(default)]
pub(crate) release_year: u16,
#[serde(default)]
pub(crate) external_metadata_id: Option<String>,
#[serde(default)]
pub(crate) poster_url: Option<String>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
}
pub fn watchlist_to_ap_object(
ap_id: Url,
actor_url: Url,
movie_title: String,
release_year: u16,
external_metadata_id: Option<String>,
poster_url: Option<String>,
added_at: chrono::DateTime<chrono::Utc>,
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;

View File

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

View File

@@ -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")
}

View File

@@ -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<dyn RemoteWatchlistRepository>,
}
#[async_trait]
impl ApObjectHandler for WatchlistObjectHandler {
async fn get_local_objects_for_user(
&self,
_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
Ok(vec![])
}
async fn get_local_objects_page(
&self,
_user_id: uuid::Uuid,
_before: Option<chrono::DateTime<Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, chrono::DateTime<Utc>)>> {
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<u64> {
Ok(0)
}
}

View File

@@ -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<EventPayload> 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 })

View File

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

View File

@@ -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<dyn ObjectStore>,
@@ -34,12 +29,8 @@ impl ImageStorageAdapter {
impl ImageStorage for ImageStorageAdapter {
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError> {
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())

View File

@@ -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}")
}

View File

@@ -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(()),
};

View File

@@ -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<Vec<RemoteWatchlistEntry>, 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::<i32, _>("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::<chrono::DateTime<chrono::Utc>, _>("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<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let actors: Vec<String> = 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::<String, _>("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<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
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 _,

View File

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

View File

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

View File

@@ -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<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, 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<models::MovieRow> = 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<MovieSummaryRow> = 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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -681,7 +704,7 @@ impl DiaryRepository for PostgresRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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<MovieStats, DomainError> {
@@ -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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -918,6 +941,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> {
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 _,
))
}

View File

@@ -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<Movie, DomainError> {
pub fn into_domain(self) -> Result<Movie, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Option<Vec<String>>,
pub runtime_minutes: Option<i64>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
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<Review, DomainError> {
pub fn into_domain(self) -> Result<Review, DomainError> {
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<DiaryEntry, DomainError> {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
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<FeedEntry, DomainError> {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
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<UserSummary, DomainError> {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,

View File

@@ -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()
}
}

View File

@@ -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<bool, 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)?;
Ok(result.rows_affected() > 0)
}
async fn get_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, 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::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
added_at: parse_datetime(&row.try_get::<String, _>("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::<Result<Vec<_>, 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<bool, DomainError> {
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)
}
}

View File

@@ -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<Vec<RemoteWatchlistEntry>, 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::<chrono::Utc>::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::<i64, _>("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<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let actors: Vec<String> = 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::<String, _>("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<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
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 _,

View File

@@ -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<Row> = 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(

View File

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

View File

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

View File

@@ -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<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, 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<models::MovieRow> = 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<MovieSummaryRow> = 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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -674,7 +701,7 @@ impl DiaryRepository for SqliteMovieRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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<MovieStats, DomainError> {
@@ -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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -909,6 +936,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> {
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 _,
))
}

View File

@@ -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<Movie, DomainError> {
pub fn into_domain(self) -> Result<Movie, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Option<String>,
pub runtime_minutes: Option<i64>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
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<Review, DomainError> {
pub fn into_domain(self) -> Result<Review, DomainError> {
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<DiaryEntry, DomainError> {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
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<FeedEntry, DomainError> {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
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<UserSummary, DomainError> {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
}
impl WatchlistRow {
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
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, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))

View File

@@ -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()
}
}

View File

@@ -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<bool, 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)?;
Ok(result.rows_affected() > 0)
}
async fn get_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let uid = user_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let rows: Vec<WatchlistRow> = 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::<Result<Vec<_>, _>>()?;
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<bool, DomainError> {
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)
}
}

View File

@@ -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<String>,
}
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
FollowingTemplate {
ctx: data.ctx,

View File

@@ -22,6 +22,9 @@
rel="stylesheet"
/>
<link rel="icon" type="image/webp" href="/static/logo.webp" />
<link rel="apple-touch-icon" href="/static/logo.webp" />
<link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#e5c034" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>

View File

@@ -2,6 +2,7 @@
{% block content %}
<div class="movie-detail">
{# ── Hero ── #}
<article class="entry" style="margin-bottom:1.5rem">
{% if let Some(poster) = movie.poster_path() %}
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
@@ -11,15 +12,46 @@
{{ movie.title().value() }}
<span class="year">({{ movie.release_year().value() }})</span>
</div>
{% if let Some(dir) = movie.director() %}
<div class="director">{{ dir }}</div>
<div class="movie-meta">
{% 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 %}
</div>
{% if let Some(p) = profile %}
{% if !p.genres.is_empty() %}
<div class="genre-pills">
{% for g in &p.genres %}<span class="genre-pill">{{ g.name }}</span>{% endfor %}
</div>
{% endif %}
{% if let Some(tagline) = &p.tagline %}{% if !tagline.is_empty() %}
<div class="movie-tagline">"{{ tagline }}"</div>
{% endif %}{% endif %}
{% endif %}
<div style="margin-top:0.75rem">
<a href="/reviews/new" class="btn-small">+ Log a review</a>
{% if ctx.user_id.is_some() %}
{% if on_watchlist %}
<form method="post" action="/watchlist/{{ movie.id().value() }}/remove" style="display:inline">
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit" class="btn-small" style="color:#4aaa77;border-color:rgba(74,170,119,.3)">✓ On watchlist · Remove</button>
</form>
{% else %}
<form method="post" action="/watchlist/add" style="display:inline">
<input type="hidden" name="movie_id" value="{{ movie.id().value() }}">
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit" class="btn-small"> Want to watch</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</article>
{# ── Stats ── #}
<div class="stats-bar">
{% if let Some(avg) = stats.avg_rating %}
<div class="stat-box">
@@ -47,6 +79,46 @@
</div>
</div>
{% if let Some(p) = profile %}
{# ── Overview ── #}
{% if let Some(overview) = &p.overview %}{% if !overview.is_empty() %}
<p class="movie-overview">{{ overview }}</p>
{% endif %}{% endif %}
{# ── Cast ── #}
{% if !p.cast.is_empty() %}
<div class="feed-section-label">CAST</div>
<div class="cast-strip">
{% for (i, member) in p.cast.iter().enumerate() %}{% if i < 10 %}
<div class="cast-card">
{% if let Some(path) = &member.profile_path %}
<img src="https://image.tmdb.org/t/p/w185{{ path }}" alt="{{ member.name }}" loading="lazy">
{% else %}
<img src="/static/person-placeholder.svg" alt="{{ member.name }}">
{% endif %}
<div class="cast-name">{{ member.name }}</div>
<div class="cast-char">{{ member.character }}</div>
</div>
{% endif %}{% endfor %}
</div>
{% endif %}
{# ── Crew ── #}
{% if !p.crew.is_empty() %}
<div class="feed-section-label">CREW</div>
<ul class="crew-list">
{% for member in &p.crew %}
{% if member.job == "Screenplay" || member.job == "Story" || member.job == "Original Music Composer" || member.job == "Director of Photography" %}
<li><span class="crew-role">{{ member.job }}</span>{{ member.name }}</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
{% endif %}
{# ── Reviews ── #}
<div class="feed-section-label">REVIEWS</div>
<div class="diary">
{% for entry in reviews %}

View File

@@ -68,6 +68,7 @@
</section>
<section class="profile-manage">
<h3>Account</h3>
<a href="/users/{{ profile_user_id }}/watchlist">Watchlist</a>
<a href="/settings/profile">Profile settings</a>
<a href="/social/blocked">Blocked users</a>
{% if ctx.is_admin %}

View File

@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block content %}
<div class="movie-detail">
<div class="entry-title" style="margin-bottom:1rem">Watchlist</div>
{% if is_owner %}
{% if let Some(err) = &error %}
<p class="form-error">{{ err }}</p>
{% endif %}
<form method="post" action="/watchlist/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem;align-items:flex-end">
<div style="flex:1;min-width:200px">
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Title or TMDB ID</label>
<input type="text" name="query" placeholder='e.g. "Dune" or tmdb:438631' style="width:100%;box-sizing:border-box">
</div>
<div style="width:90px">
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Year</label>
<input type="number" name="year" placeholder="2021" min="1888" max="2099" style="width:100%;box-sizing:border-box">
</div>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
<button type="submit" class="btn-small" style="height:2.25rem">Add</button>
</form>
{% endif %}
{% if display_entries.is_empty() %}
<p class="empty">No movies on the watchlist yet.</p>
{% else %}
<div class="diary">
{% for entry in display_entries %}
<article class="entry">
{% if let Some(url) = &entry.poster_url %}
<div class="poster"><img src="{{ url }}" alt=""></div>
{% endif %}
<div class="entry-body">
<div class="entry-title">
{% if let Some(url) = &entry.movie_url %}
<a href="{{ url }}" class="movie-title-link">{{ entry.movie_title }}</a>
{% else %}
{{ entry.movie_title }}
{% endif %}
<span class="year">({{ entry.release_year }})</span>
</div>
<div class="feed-meta" style="margin-top:0.4rem">
<span class="feed-time">Added {{ entry.added_at }}</span>
</div>
{% if let Some(remove_url) = &entry.remove_url %}
<form method="post" action="{{ remove_url }}" style="margin-top:0.5rem">
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Remove</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
</div>
<nav class="pagination">
{% if current_offset >= limit %}
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset - limit }}&limit={{ limit }}" class="page-nav">&larr; Prev</a>
{% endif %}
{% if has_more %}
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset + limit }}&limit={{ limit }}" class="page-nav">Next &rarr;</a>
{% endif %}
</nav>
{% endif %}
</div>
{% endblock %}