feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
77
crates/adapters/activitypub/src/composite_handler.rs
Normal file
77
crates/adapters/activitypub/src/composite_handler.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
82
crates/adapters/activitypub/src/watchlist_handler.rs
Normal file
82
crates/adapters/activitypub/src/watchlist_handler.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -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(()),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
9
crates/adapters/postgres/migrations/0016_watchlist.sql
Normal file
9
crates/adapters/postgres/migrations/0016_watchlist.sql
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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 _,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
169
crates/adapters/postgres/src/watchlist.rs
Normal file
169
crates/adapters/postgres/src/watchlist.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 _,
|
||||
|
||||
@@ -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(
|
||||
|
||||
9
crates/adapters/sqlite/migrations/0016_watchlist.sql
Normal file
9
crates/adapters/sqlite/migrations/0016_watchlist.sql
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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 _,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
154
crates/adapters/sqlite/src/watchlist.rs
Normal file
154
crates/adapters/sqlite/src/watchlist.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
68
crates/adapters/template-askama/templates/watchlist.html
Normal file
68
crates/adapters/template-askama/templates/watchlist.html
Normal 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">← Prev</a>
|
||||
{% endif %}
|
||||
{% if has_more %}
|
||||
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset + limit }}&limit={{ limit }}" class="page-nav">Next →</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user