export feature

This commit is contained in:
2026-05-09 20:51:29 +02:00
parent 1eaa3ca8a6
commit dcfc17f542
57 changed files with 2245 additions and 624 deletions

View File

@@ -1,11 +1,11 @@
use async_trait::async_trait;
use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{MovieRepository, ReviewRepository},
value_objects::{ReviewId, UserId},
};
use domain::ports::EventHandler;
use std::sync::Arc;
use activitypub_base::ActivityPubService;
@@ -27,7 +27,12 @@ impl ActivityPubEventHandler {
review_repository: Arc<dyn ReviewRepository>,
base_url: String,
) -> Self {
Self { ap_service, movie_repository, review_repository, base_url }
Self {
ap_service,
movie_repository,
review_repository,
base_url,
}
}
}
@@ -35,7 +40,9 @@ impl ActivityPubEventHandler {
impl EventHandler for ActivityPubEventHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::ReviewLogged { review_id, user_id, .. } => self
DomainEvent::ReviewLogged {
review_id, user_id, ..
} => self
.on_review_logged(user_id, review_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
@@ -45,11 +52,7 @@ impl EventHandler for ActivityPubEventHandler {
}
impl ActivityPubEventHandler {
async fn on_review_logged(
&self,
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let review = match self.review_repository.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
@@ -58,16 +61,33 @@ impl ActivityPubEventHandler {
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten();
let movie_title = movie.as_ref()
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let poster_url = movie.as_ref()
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(&review, ap_id.clone(), actor, movie_title, release_year, poster_url);
let obj = review_to_ap_object(
&review,
ap_id.clone(),
actor,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service

View File

@@ -3,8 +3,8 @@ pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod user_adapter;
pub(crate) mod urls;
pub mod user_adapter;
// Re-export the generic base types that callers need
pub use activitypub_base::{

View File

@@ -36,10 +36,17 @@ pub fn review_to_ap_object(
) -> ReviewObject {
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string());
let year_str = if release_year > 0 { format!(" ({})", release_year) } else { String::new() };
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let watched_str = format!("Watched: {}", review.watched_at().format("%b %-d, %Y"));
let content = match &comment_text {
Some(c) => format!("{} {}{}\n{}\n{}", stars, movie_title, year_str, c, watched_str),
Some(c) => format!(
"{} {}{}\n{}\n{}",
stars, movie_title, year_str, c, watched_str
),
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
};

View File

@@ -11,10 +11,19 @@ pub trait ActivityPubPort: Send + Sync {
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>;
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
}
@@ -38,16 +47,27 @@ impl ActivityPubPort for ActivityPubService {
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unfollow(local_user_id, actor_url).await
}
async fn accept_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> {
async fn accept_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.accept_follower(local_user_id, remote_actor_url).await
}
async fn reject_follower(&self, local_user_id: Uuid, remote_actor_url: &str) -> anyhow::Result<()> {
async fn reject_follower(
&self,
local_user_id: Uuid,
remote_actor_url: &str,
) -> anyhow::Result<()> {
self.reject_follower(local_user_id, remote_actor_url).await
}
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await
}
async fn get_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
@@ -59,15 +79,37 @@ pub struct NoopActivityPubService;
#[async_trait]
impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> { Ok(String::new()) }
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) }
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> { Ok(0) }
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> { Ok(vec![]) }
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { Ok(()) }
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new())
}
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -9,7 +9,7 @@ use domain::{
};
use url::Url;
use crate::objects::{review_to_ap_object, ReviewObject};
use crate::objects::{ReviewObject, review_to_ap_object};
use crate::remote_review_repository::RemoteReviewRepository;
use crate::urls::{actor_url, review_url};
@@ -27,7 +27,10 @@ impl ApObjectHandler for ReviewObjectHandler {
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id);
let history = self.diary_repository.get_user_history(&domain_user_id).await?;
let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new();
for entry in history {
@@ -39,18 +42,33 @@ impl ApObjectHandler for ReviewObjectHandler {
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);
let movie = self.movie_repository.get_movie_by_id(review.movie_id()).await.ok().flatten();
let movie_title = movie.as_ref()
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref()
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie.as_ref()
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
let obj = review_to_ap_object(review, ap_id.clone(), actor_url, movie_title, release_year, poster_url);
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
);
let json = serde_json::to_value(obj)?;
results.push((ap_id, json));
}
@@ -73,8 +91,14 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, obj.movie_title.as_bytes()));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url_str.as_bytes()));
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
obj.movie_title.as_bytes(),
));
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
actor_url_str.as_bytes(),
));
let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?;
@@ -86,11 +110,19 @@ impl ApObjectHandler for ReviewObjectHandler {
comment,
obj.watched_at.naive_utc(),
obj.published.naive_utc(),
ReviewSource::Remote { actor_url: actor_url_str },
ReviewSource::Remote {
actor_url: actor_url_str,
},
);
self.review_store
.save_remote_review(&review, obj.id.as_str(), &obj.movie_title, obj.release_year, obj.poster_url.as_deref())
.save_remote_review(
&review,
obj.id.as_str(),
&obj.movie_title,
obj.release_year,
obj.poster_url.as_deref(),
)
.await?;
Ok(())

View File

@@ -1,5 +1,5 @@
use url::Url;
use domain::value_objects::ReviewId;
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {

View File

@@ -18,8 +18,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username;
let uname = Username::new(username.to_string())
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let uname =
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
id: u.id().value(),
username: u.username().value().to_string(),