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

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

View File

@@ -200,7 +200,7 @@ pub struct UndoActivity {
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
@@ -223,19 +223,51 @@ impl Activity for UndoActivity {
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or("");
match obj_type {
"Follow" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) {
if let Ok(url) = Url::parse(obj_url) {
if let Some(user_id) = crate::urls::extract_user_id_from_url(&url) {
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
}
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str {
if let Ok(ap_id) = Url::parse(ap_id_str) {
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
}
other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
}
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
Ok(())
}
}
@@ -430,6 +462,56 @@ impl Activity for AnnounceActivity {
}
}
// --- Add ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")]
pub struct AddType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AddType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for AddActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
@@ -503,6 +585,8 @@ pub enum InboxActivities {
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Add")]
Add(AddActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
}

View File

@@ -34,11 +34,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) {
if let Ok(url) = Url::parse(inbox_str) {
if seen.insert(inbox_str.to_string())
&& let Ok(url) = Url::parse(inbox_str) {
inboxes.push(url);
}
}
}
inboxes
}
@@ -228,7 +227,7 @@ impl ActivityPubService {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: follow,
object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?,
};
let sends = SendActivityTask::prepare(
@@ -565,6 +564,131 @@ impl ActivityPubService {
Ok(())
}
/// Broadcast an Add(WatchlistObject) activity to all accepted followers.
pub async fn broadcast_add_to_followers(
&self,
local_user_id: uuid::Uuid,
ap_id: Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let add = crate::activities::AddActivity {
id: ap_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object,
};
let add_with_ctx = WithContext::new_default(add);
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Add deliveries failed");
}
Ok(())
}
/// Broadcast an Undo(Add) activity to all accepted followers.
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let undo_id = crate::urls::activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = crate::activities::UndoActivity {
id: undo_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({
"type": "Add",
"id": watchlist_entry_ap_id.as_str(),
"object": { "id": watchlist_entry_ap_id.as_str() }
}),
};
let undo_with_ctx = WithContext::new_default(undo);
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed");
}
Ok(())
}
/// Broadcast an Update(Note) activity to all accepted followers for an edited review.
pub async fn broadcast_update_to_followers(
&self,
@@ -812,7 +936,7 @@ impl ActivityPubService {
data.federation_repo
.update_following_status(
local_user_id,
&target_actor_url.to_string(),
target_actor_url.as_ref(),
FollowingStatus::Accepted,
)
.await?;

View File

@@ -0,0 +1,77 @@
use std::sync::Arc;
use activitypub_base::ApObjectHandler;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
pub struct CompositeObjectHandler {
pub review: Arc<ReviewObjectHandler>,
pub watchlist: Arc<WatchlistObjectHandler>,
}
#[async_trait]
impl ApObjectHandler for CompositeObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
self.review.get_local_objects_for_user(user_id).await
}
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
self.review.get_local_objects_page(user_id, before, limit).await
}
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
if object.get("rating").is_some() {
self.review.on_create(ap_id, actor_url, object).await
} else if object.get("movieTitle").is_some() {
self.watchlist.on_create(ap_id, actor_url, object).await
} else {
tracing::debug!(ap_id = %ap_id, "ignoring Create for unknown object type");
Ok(())
}
}
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
if object.get("rating").is_some() {
self.review.on_update(ap_id, actor_url, object).await
} else {
Ok(())
}
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_delete(ap_id, actor_url).await?;
self.watchlist.on_delete(ap_id, actor_url).await?;
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_actor_removed(actor_url).await?;
self.watchlist.on_actor_removed(actor_url).await?;
Ok(())
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.review.count_local_posts().await
}
}

View File

@@ -3,7 +3,7 @@ use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{MovieRepository, ReviewRepository},
ports::{MovieRepository, ReviewRepository, WatchlistRepository},
value_objects::{ReviewId, UserId},
};
use std::sync::Arc;
@@ -17,6 +17,7 @@ pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
watchlist_repository: Arc<dyn WatchlistRepository>,
base_url: String,
}
@@ -25,12 +26,14 @@ impl ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
movie_repository: Arc<dyn MovieRepository>,
review_repository: Arc<dyn ReviewRepository>,
watchlist_repository: Arc<dyn WatchlistRepository>,
base_url: String,
) -> Self {
Self {
ap_service,
movie_repository,
review_repository,
watchlist_repository,
base_url,
}
}
@@ -57,6 +60,21 @@ impl EventHandler for ActivityPubEventHandler {
.broadcast_actor_update(user_id.value())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryAdded {
user_id,
movie_id,
movie_title,
release_year,
external_metadata_id,
added_at,
} => self
.on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
.on_watchlist_removed(user_id, movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
_ => Ok(()),
}
}
@@ -162,4 +180,58 @@ impl ActivityPubEventHandler {
.await?;
Ok(())
}
async fn on_watchlist_added(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
movie_title: &str,
release_year: u16,
external_metadata_id: &Option<String>,
added_at: &chrono::NaiveDateTime,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let actor = actor_url(&self.base_url, user_id.value());
let poster_url = self
.movie_repository
.get_movie_by_id(movie_id)
.await
.ok()
.flatten()
.and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value())));
let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
let obj = crate::objects::watchlist_to_ap_object(
ap_id.clone(),
actor,
movie_title.to_string(),
release_year,
external_metadata_id.clone(),
poster_url,
added_at_utc,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_add_to_followers(user_id.value(), ap_id, json)
.await?;
Ok(())
}
async fn on_watchlist_removed(
&self,
user_id: &UserId,
movie_id: &domain::value_objects::MovieId,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
self.ap_service
.broadcast_undo_add_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}

View File

@@ -1,8 +1,10 @@
pub mod composite_handler;
pub mod event_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod watchlist_handler;
pub(crate) mod urls;
pub mod user_adapter;
@@ -25,25 +27,36 @@ pub struct ActivityPubWire {
}
pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
watchlist_repo: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String,
allow_registration: bool,
) -> anyhow::Result<ActivityPubWire> {
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo),
diary_repository: std::sync::Arc::clone(&diary_repo),
review_store,
base_url: base_url.clone(),
});
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
remote_watchlist_repo,
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
watchlist: watchlist_handler,
});
let concrete = std::sync::Arc::new(
ActivityPubService::new(
federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo),
diary_repository: diary_repo,
review_store,
base_url: base_url.clone(),
}),
composite,
base_url.clone(),
allow_registration,
"movies-diary".to_string(),
@@ -57,6 +70,7 @@ pub async fn wire(
std::sync::Arc::clone(&concrete),
movie_repo,
review_repo,
watchlist_repo,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -97,6 +97,72 @@ pub fn review_to_ap_object(
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WatchlistObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: chrono::DateTime<chrono::Utc>,
pub(crate) movie_title: String,
#[serde(default)]
pub(crate) release_year: u16,
#[serde(default)]
pub(crate) external_metadata_id: Option<String>,
#[serde(default)]
pub(crate) poster_url: Option<String>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
}
pub fn watchlist_to_ap_object(
ap_id: Url,
actor_url: Url,
movie_title: String,
release_year: u16,
external_metadata_id: Option<String>,
poster_url: Option<String>,
added_at: chrono::DateTime<chrono::Utc>,
base_url: &str,
) -> WatchlistObject {
let year_str = if release_year > 0 {
format!(" ({})", release_year)
} else {
String::new()
};
let content = format!("📋 {}{} — want to watch", movie_title, year_str);
let normalized = normalize_hashtag(&movie_title);
let tag = vec![
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
.expect("valid base_url"),
name: "#MoviesDiary".to_string(),
},
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
.expect("valid base_url"),
name: format!("#{}", normalized),
},
];
WatchlistObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url,
content,
published: added_at,
movie_title,
release_year,
external_metadata_id,
poster_url,
tag,
}
}
#[cfg(test)]
#[path = "tests/objects.rs"]
mod tests;

View File

@@ -100,11 +100,10 @@ impl ApObjectHandler for ReviewObjectHandler {
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
if let Some(cutoff) = before {
if published >= cutoff {
if let Some(cutoff) = before
&& published >= cutoff {
continue;
}
}
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);

View File

@@ -12,3 +12,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id))
.expect("base_url is always a valid URL prefix")
}

View File

@@ -0,0 +1,82 @@
use std::sync::Arc;
use activitypub_base::ApObjectHandler;
use async_trait::async_trait;
use chrono::Utc;
use domain::{models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
use url::Url;
use crate::objects::WatchlistObject;
pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
}
#[async_trait]
impl ApObjectHandler for WatchlistObjectHandler {
async fn get_local_objects_for_user(
&self,
_user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
Ok(vec![])
}
async fn get_local_objects_page(
&self,
_user_id: uuid::Uuid,
_before: Option<chrono::DateTime<Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, chrono::DateTime<Utc>)>> {
Ok(vec![])
}
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: WatchlistObject = serde_json::from_value(object)?;
let added_at = obj.published;
let entry = RemoteWatchlistEntry {
ap_id: ap_id.as_str().to_string(),
actor_url: actor_url.as_str().to_string(),
movie_title: obj.movie_title,
release_year: obj.release_year,
external_metadata_id: obj.external_metadata_id,
poster_url: obj.poster_url,
added_at,
};
self.remote_watchlist_repo.save(entry).await?;
tracing::info!(ap_id = %ap_id, "saved remote watchlist entry");
Ok(())
}
async fn on_update(
&self,
_ap_id: &Url,
_actor_url: &Url,
_object: serde_json::Value,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.remote_watchlist_repo
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
.await?;
tracing::info!(ap_id = %ap_id, "removed remote watchlist entry");
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.remote_watchlist_repo
.remove_all_by_actor(actor_url.as_str())
.await?;
Ok(())
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
}

View File

@@ -119,6 +119,10 @@ impl From<&DomainEvent> for EventPayload {
}
}
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => {
// federation-only events; not serialized via EventPayload
unreachable!("watchlist events are handled by the AP event handler directly")
}
}
}
}
@@ -154,7 +158,7 @@ impl TryFrom<EventPayload> for DomainEvent {
EventPayload::MovieDeleted { movie_id, poster_path } => {
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
let poster_path = poster_path
.map(|p| PosterPath::new(p))
.map(PosterPath::new)
.transpose()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })

View File

@@ -43,7 +43,7 @@ impl EventHandler for ImageConversionHandler {
let converted = tokio::task::spawn_blocking(move || convert(bytes, format))
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.map_err(|e| DomainError::InfrastructureError(e))?;
.map_err(DomainError::InfrastructureError)?;
let ext = format.extension();
let new_key = format!("{key}{ext}");

View File

@@ -7,14 +7,9 @@ use domain::{
events::DomainEvent,
ports::{EventHandler, ImageStorage},
};
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use object_store::{ObjectStore, path::Path};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
infer::get(bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream")
}
pub struct ImageStorageAdapter {
store: Arc<dyn ObjectStore>,
@@ -34,12 +29,8 @@ impl ImageStorageAdapter {
impl ImageStorage for ImageStorageAdapter {
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError> {
let path = Path::from(key);
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() };
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.put(&path, image_bytes.to_vec().into())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(key.to_string())

View File

@@ -10,6 +10,9 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::UserUpdated { .. } => "user.updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
DomainEvent::ImageStored { .. } => "image.stored",
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => {
unreachable!("watchlist events are not published to NATS")
}
};
format!("{prefix}.{suffix}")
}

View File

@@ -69,6 +69,19 @@ impl EventHandler for PosterSyncHandler {
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
(movie_id.value(), external_metadata_id.value().to_owned())
}
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
// Only sync poster if the movie doesn't have one yet
let already_has_poster = self
.movie_repository
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
.await?
.map(|m| m.poster_path().is_some())
.unwrap_or(false);
if already_has_poster {
return Ok(());
}
(movie_id.value(), external_metadata_id.clone())
}
_ => return Ok(()),
};

View File

@@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository;
use activitypub_base::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{Review, ReviewSource};
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
use domain::ports::RemoteWatchlistRepository;
fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
@@ -609,13 +610,104 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
}
}
#[async_trait]
impl RemoteWatchlistRepository for PostgresFederationRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
ON CONFLICT(ap_id) DO UPDATE SET \
movie_title=excluded.movie_title, release_year=excluded.release_year, \
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
)
.bind(&entry.ap_id)
.bind(&entry.actor_url)
.bind(&entry.movie_title)
.bind(entry.release_year as i32)
.bind(&entry.external_metadata_id)
.bind(&entry.poster_url)
.bind(entry.added_at)
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> {
sqlx::query(
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2",
)
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let rows = sqlx::query(
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC",
)
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(|row| {
Ok(RemoteWatchlistEntry {
ap_id: row.try_get("ap_id").unwrap_or_default(),
actor_url: row.try_get("actor_url").unwrap_or_default(),
movie_title: row.try_get("movie_title").unwrap_or_default(),
release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16,
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
poster_url: row.try_get("poster_url").ok().flatten(),
added_at: row.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at")
.unwrap_or_else(|_| chrono::Utc::now()),
})
}).collect()
}
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
.into_iter()
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
.collect();
let target = actors.into_iter().find(|url| {
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid
});
match target {
None => Ok(vec![]),
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
}
}
}
pub fn wire(pool: sqlx::PgPool) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS watchlist_entries (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL,
UNIQUE(user_id, movie_id)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC);

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries (
ap_id TEXT PRIMARY KEY NOT NULL,
actor_url TEXT NOT NULL,
movie_title TEXT NOT NULL,
release_year INTEGER NOT NULL,
external_metadata_id TEXT,
poster_url TEXT,
added_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor
ON ap_remote_watchlist_entries(actor_url);

View File

@@ -19,10 +19,11 @@ mod models;
mod persons;
mod profile;
mod users;
mod watchlist;
use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
UserTotalsRow, datetime_to_str,
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow,
MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str,
};
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
@@ -31,6 +32,7 @@ pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use users::PostgresUserRepository;
pub use watchlist::PostgresWatchlistRepository;
fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
@@ -300,7 +302,7 @@ impl MovieRepository for PostgresRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.transpose()
}
@@ -314,7 +316,7 @@ impl MovieRepository for PostgresRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.transpose()
}
@@ -335,7 +337,7 @@ impl MovieRepository for PostgresRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.collect()
}
@@ -383,21 +385,34 @@ impl MovieRepository for PostgresRepository {
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \
ORDER BY title ASC \
LIMIT $2 OFFSET $3",
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT $4 OFFSET $5",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
@@ -405,17 +420,25 @@ impl MovieRepository for PostgresRepository {
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)",
"SELECT COUNT(DISTINCT m.id) \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
@@ -480,7 +503,7 @@ impl ReviewRepository for PostgresRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::to_domain)
.map(ReviewRow::into_domain)
.transpose()
}
@@ -540,7 +563,7 @@ impl DiaryRepository for PostgresRepository {
let items = rows
.into_iter()
.map(DiaryRow::to_domain)
.map(DiaryRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -681,7 +704,7 @@ impl DiaryRepository for PostgresRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -704,7 +727,7 @@ impl DiaryRepository for PostgresRepository {
.await
.map_err(Self::map_err)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.to_domain()?;
.into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
@@ -718,7 +741,7 @@ impl DiaryRepository for PostgresRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::to_domain)
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
@@ -742,7 +765,7 @@ impl DiaryRepository for PostgresRepository {
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::to_domain).collect()
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
@@ -763,7 +786,7 @@ impl DiaryRepository for PostgresRepository {
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::to_domain)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
@@ -808,7 +831,7 @@ impl DiaryRepository for PostgresRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -918,6 +941,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> {
use anyhow::Context;
@@ -934,6 +958,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone()));
Ok((
pool.clone(),
@@ -945,5 +970,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
import_session_repo as _,
import_profile_repo as _,
movie_profile_repo as _,
watchlist_repo as _,
))
}

View File

@@ -1,7 +1,7 @@
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary},
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId,
@@ -20,7 +20,7 @@ pub(crate) struct MovieRow {
}
impl MovieRow {
pub fn to_domain(self) -> Result<Movie, DomainError> {
pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
let external_metadata_id = self
.external_metadata_id
@@ -40,6 +40,43 @@ impl MovieRow {
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct MovieSummaryRow {
pub id: String,
pub external_metadata_id: Option<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Option<Vec<String>>,
pub runtime_minutes: Option<i64>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
Ok(MovieSummary {
movie,
genres: self.genres.unwrap_or_default(),
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
original_language: self.original_language,
overview: self.overview,
collection_name: self.collection_name,
})
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct ReviewRow {
pub id: String,
@@ -53,7 +90,7 @@ pub(crate) struct ReviewRow {
}
impl ReviewRow {
pub fn to_domain(self) -> Result<Review, DomainError> {
pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
@@ -90,7 +127,7 @@ pub(crate) struct DiaryRow {
}
impl DiaryRow {
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
@@ -99,7 +136,7 @@ impl DiaryRow {
director: self.director,
poster_path: self.poster_path,
}
.to_domain()?;
.into_domain()?;
let review = ReviewRow {
id: self.review_id,
movie_id: self.movie_id,
@@ -110,7 +147,7 @@ impl DiaryRow {
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.to_domain()?;
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
}
}
@@ -135,7 +172,7 @@ pub(crate) struct FeedRow {
}
impl FeedRow {
pub fn to_domain(self) -> Result<FeedEntry, DomainError> {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
@@ -152,7 +189,7 @@ impl FeedRow {
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.to_domain()?;
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))
}
}
@@ -170,7 +207,7 @@ pub(crate) struct MovieStatsRow {
}
impl MovieStatsRow {
pub fn to_domain(self) -> domain::models::MovieStats {
pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
@@ -195,7 +232,7 @@ pub(crate) struct UserSummaryRow {
}
impl UserSummaryRow {
pub fn to_domain(self) -> Result<UserSummary, DomainError> {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,

View File

@@ -231,7 +231,7 @@ impl UserRepository for PostgresUserRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(UserSummaryRow::to_domain)
.map(UserSummaryRow::into_domain)
.collect()
}
}

View File

@@ -0,0 +1,169 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
ports::WatchlistRepository,
value_objects::{MovieId, UserId, WatchlistEntryId},
};
use sqlx::{PgPool, Row};
use crate::models::{parse_uuid, parse_datetime, MovieRow};
pub struct PostgresWatchlistRepository {
pool: PgPool,
}
impl PostgresWatchlistRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl WatchlistRepository for PostgresWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
let id = entry.id.value().to_string();
let user_id = entry.user_id.value().to_string();
let movie_id = entry.movie_id.value().to_string();
let added_at = entry.added_at;
sqlx::query(
"INSERT INTO watchlist_entries (id, user_id, movie_id, added_at) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (user_id, movie_id) DO NOTHING",
)
.bind(&id)
.bind(&user_id)
.bind(&movie_id)
.bind(added_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
"Watchlist entry for movie {} not found",
mid
)));
}
Ok(())
}
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0)
}
async fn get_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let uid = user_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let rows = sqlx::query(
"SELECT w.id, w.user_id, w.movie_id, \
to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, \
m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \
m.director, m.poster_path \
FROM watchlist_entries w \
JOIN movies m ON m.id = w.movie_id \
WHERE w.user_id = $1 \
ORDER BY w.added_at DESC \
LIMIT $2 OFFSET $3",
)
.bind(&uid)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(|row| {
let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
added_at: parse_datetime(&row.try_get::<String, _>("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?,
};
let movie = MovieRow {
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
}
.into_domain()?;
Ok(WatchlistWithMovie { entry, movie })
})
.collect::<Result<Vec<_>, DomainError>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
)
.bind(&uid)
.bind(&mid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count > 0)
}
}

View File

@@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository;
use activitypub_base::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{Review, ReviewSource};
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
use domain::ports::RemoteWatchlistRepository;
fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
@@ -672,13 +673,107 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
}
}
#[async_trait]
impl RemoteWatchlistRepository for SqliteFederationRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
VALUES (?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(ap_id) DO UPDATE SET \
movie_title=excluded.movie_title, release_year=excluded.release_year, \
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
)
.bind(&entry.ap_id)
.bind(&entry.actor_url)
.bind(&entry.movie_title)
.bind(entry.release_year as i64)
.bind(&entry.external_metadata_id)
.bind(&entry.poster_url)
.bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string())
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> {
sqlx::query(
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?",
)
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let rows = sqlx::query(
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC",
)
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
rows.into_iter().map(|row| {
let added_at_str: String = row.try_get("added_at").unwrap_or_default();
let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
.map(|dt| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc))
.unwrap_or_else(|_| chrono::Utc::now());
Ok(RemoteWatchlistEntry {
ap_id: row.try_get("ap_id").unwrap_or_default(),
actor_url: row.try_get("actor_url").unwrap_or_default(),
movie_title: row.try_get("movie_title").unwrap_or_default(),
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16,
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
poster_url: row.try_get("poster_url").ok().flatten(),
added_at,
})
}).collect()
}
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
.into_iter()
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
.collect();
let target = actors.into_iter().find(|url| {
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid
});
match target {
None => Ok(vec![]),
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
}
}
}
pub fn wire(pool: sqlx::SqlitePool) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,

View File

@@ -160,7 +160,7 @@ impl SqliteSearchAdapter {
}
let total: u64 = if let Some(text) = &query.text {
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(DISTINCT m.id)
FROM movies_fts fts
@@ -198,7 +198,7 @@ impl SqliteSearchAdapter {
};
let rows: Vec<Row> = if let Some(text) = &query.text {
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
GROUP_CONCAT(DISTINCT mg.name) AS genres
@@ -273,7 +273,7 @@ impl SqliteSearchAdapter {
let limit = query.page.limit as i64;
let offset = query.page.offset as i64;
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
let total: u64 = {
let count: i64 = sqlx::query_scalar(

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS watchlist_entries (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
added_at TEXT NOT NULL,
UNIQUE(user_id, movie_id)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC);

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries (
ap_id TEXT PRIMARY KEY NOT NULL,
actor_url TEXT NOT NULL,
movie_title TEXT NOT NULL,
release_year INTEGER NOT NULL,
external_metadata_id TEXT,
poster_url TEXT,
added_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor
ON ap_remote_watchlist_entries(actor_url);

View File

@@ -20,10 +20,11 @@ mod models;
mod persons;
mod profile;
mod users;
mod watchlist;
use models::{
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
UserTotalsRow, datetime_to_str,
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow,
MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str,
};
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
@@ -32,6 +33,7 @@ pub use import_session::SqliteImportSessionRepository;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use users::SqliteUserRepository;
pub use watchlist::SqliteWatchlistRepository;
fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
@@ -307,7 +309,7 @@ impl MovieRepository for SqliteMovieRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.transpose()
}
@@ -322,7 +324,7 @@ impl MovieRepository for SqliteMovieRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.transpose()
}
@@ -344,7 +346,7 @@ impl MovieRepository for SqliteMovieRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::to_domain)
.map(MovieRow::into_domain)
.collect()
}
@@ -391,22 +393,37 @@ impl MovieRepository for SqliteMovieRepository {
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
filter: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE (? IS NULL OR LOWER(title) LIKE ?) \
ORDER BY title ASC \
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
GROUP_CONCAT(g.name) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
AND (? IS NULL OR p.original_language = ?) \
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(&pattern)
.bind(language)
.bind(language)
.bind(genre)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
@@ -414,18 +431,28 @@ impl MovieRepository for SqliteMovieRepository {
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)",
"SELECT COUNT(DISTINCT m.id) \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
AND (? IS NULL OR p.original_language = ?) \
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?)))",
)
.bind(&pattern)
.bind(&pattern)
.bind(language)
.bind(language)
.bind(genre)
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
@@ -488,7 +515,7 @@ impl ReviewRepository for SqliteMovieRepository {
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::to_domain)
.map(ReviewRow::into_domain)
.transpose()
}
@@ -547,7 +574,7 @@ impl DiaryRepository for SqliteMovieRepository {
let items = rows
.into_iter()
.map(DiaryRow::to_domain)
.map(DiaryRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -674,7 +701,7 @@ impl DiaryRepository for SqliteMovieRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -698,7 +725,7 @@ impl DiaryRepository for SqliteMovieRepository {
.await
.map_err(Self::map_err)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.to_domain()?;
.into_domain()?;
let viewings = sqlx::query_as!(
ReviewRow,
@@ -710,7 +737,7 @@ impl DiaryRepository for SqliteMovieRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::to_domain)
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
@@ -732,7 +759,7 @@ impl DiaryRepository for SqliteMovieRepository {
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::to_domain).collect()
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
@@ -753,7 +780,7 @@ impl DiaryRepository for SqliteMovieRepository {
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::to_domain)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
@@ -796,7 +823,7 @@ impl DiaryRepository for SqliteMovieRepository {
let items = rows
.into_iter()
.map(FeedRow::to_domain)
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
@@ -909,6 +936,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
)> {
use std::str::FromStr;
use anyhow::Context;
@@ -932,6 +960,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone()));
let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone()));
let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone()));
let watchlist_repo = std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone()));
Ok((
pool.clone(),
@@ -943,6 +972,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
import_session_repo as _,
import_profile_repo as _,
movie_profile_repo as _,
watchlist_repo as _,
))
}

View File

@@ -1,10 +1,10 @@
use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary},
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId,
ReviewId, UserId, WatchlistEntryId,
},
};
use uuid::Uuid;
@@ -20,7 +20,7 @@ pub(crate) struct MovieRow {
}
impl MovieRow {
pub fn to_domain(self) -> Result<Movie, DomainError> {
pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
let external_metadata_id = self
.external_metadata_id
@@ -40,6 +40,47 @@ impl MovieRow {
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct MovieSummaryRow {
pub id: String,
pub external_metadata_id: Option<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Option<String>,
pub runtime_minutes: Option<i64>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
let genres = self
.genres
.map(|g| g.split(',').map(str::to_string).collect())
.unwrap_or_default();
Ok(MovieSummary {
movie,
genres,
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
original_language: self.original_language,
overview: self.overview,
collection_name: self.collection_name,
})
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct ReviewRow {
pub id: String,
@@ -53,7 +94,7 @@ pub(crate) struct ReviewRow {
}
impl ReviewRow {
pub fn to_domain(self) -> Result<Review, DomainError> {
pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
@@ -91,7 +132,7 @@ pub(crate) struct DiaryRow {
}
impl DiaryRow {
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
@@ -100,7 +141,7 @@ impl DiaryRow {
director: self.director,
poster_path: self.poster_path,
}
.to_domain()?;
.into_domain()?;
let review = ReviewRow {
id: self.review_id,
@@ -112,7 +153,7 @@ impl DiaryRow {
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.to_domain()?;
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
}
@@ -131,7 +172,7 @@ pub(crate) struct MovieStatsRow {
}
impl MovieStatsRow {
pub fn to_domain(self) -> domain::models::MovieStats {
pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
@@ -168,7 +209,7 @@ pub(crate) struct FeedRow {
}
impl FeedRow {
pub fn to_domain(self) -> Result<FeedEntry, DomainError> {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
@@ -185,7 +226,7 @@ impl FeedRow {
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.to_domain()?;
.into_domain()?;
Ok(FeedEntry::new(diary, self.user_email))
}
}
@@ -199,7 +240,7 @@ pub(crate) struct UserSummaryRow {
}
impl UserSummaryRow {
pub fn to_domain(self) -> Result<UserSummary, DomainError> {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,
@@ -228,6 +269,41 @@ pub(crate) struct MonthlyRatingRow {
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct WatchlistRow {
pub id: String,
pub user_id: String,
pub movie_id: String,
pub added_at: String,
pub m_id: String,
pub external_metadata_id: Option<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
}
impl WatchlistRow {
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
added_at: parse_datetime(&self.added_at)?,
};
let movie = MovieRow {
id: self.m_id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
Ok(WatchlistWithMovie { entry, movie })
}
}
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))

View File

@@ -202,7 +202,7 @@ impl UserRepository for SqliteUserRepository {
.await
.map_err(Self::map_err)?
.into_iter()
.map(UserSummaryRow::to_domain)
.map(UserSummaryRow::into_domain)
.collect()
}
}

View File

@@ -0,0 +1,154 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
ports::WatchlistRepository,
value_objects::{MovieId, UserId},
};
use sqlx::{Row, SqlitePool};
use crate::models::{WatchlistRow, datetime_to_str};
pub struct SqliteWatchlistRepository {
pool: SqlitePool,
}
impl SqliteWatchlistRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl WatchlistRepository for SqliteWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
let id = entry.id.value().to_string();
let user_id = entry.user_id.value().to_string();
let movie_id = entry.movie_id.value().to_string();
let added_at = datetime_to_str(&entry.added_at);
sqlx::query(
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
VALUES (?, ?, ?, ?)",
)
.bind(&id)
.bind(&user_id)
.bind(&movie_id)
.bind(&added_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
"Watchlist entry for movie {} not found",
mid
)));
}
Ok(())
}
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let result = sqlx::query(
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
)
.bind(&uid)
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(result.rows_affected() > 0)
}
async fn get_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let uid = user_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let rows: Vec<WatchlistRow> = sqlx::query_as(
"SELECT w.id, w.user_id, w.movie_id, w.added_at, \
m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \
m.director, m.poster_path \
FROM watchlist_entries w \
JOIN movies m ON m.id = w.movie_id \
WHERE w.user_id = ? \
ORDER BY w.added_at DESC \
LIMIT ? OFFSET ?",
)
.bind(&uid)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let mid = movie_id.value().to_string();
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
)
.bind(&uid)
.bind(&mid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
Ok(count > 0)
}
}

View File

@@ -3,7 +3,7 @@ use application::ports::{
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData,
};
use askama::Template;
use chrono::Datelike;
@@ -101,13 +101,28 @@ struct MovieDetailTemplate<'a> {
ctx: &'a HtmlPageContext,
movie: &'a domain::models::Movie,
stats: &'a domain::models::MovieStats,
profile: Option<&'a domain::models::MovieProfile>,
reviews: &'a [domain::models::FeedEntry],
on_watchlist: bool,
current_offset: u32,
has_more: bool,
limit: u32,
histogram_max: u64,
}
#[derive(Template)]
#[template(path = "watchlist.html")]
struct WatchlistTemplate<'a> {
ctx: &'a HtmlPageContext,
owner_id: uuid::Uuid,
display_entries: &'a [application::ports::WatchlistDisplayEntry],
current_offset: u32,
has_more: bool,
limit: u32,
is_owner: bool,
error: Option<String>,
}
impl<'a> ActivityFeedTemplate<'a> {
pub fn filter_qs(&self) -> String {
let mut parts = vec![
@@ -358,6 +373,7 @@ struct ImportPreviewTemplate<'a> {
rows: &'a [ImportPreviewRow],
}
#[derive(Default)]
pub struct AskamaHtmlRenderer;
impl AskamaHtmlRenderer {
@@ -374,7 +390,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
) -> Result<String, String> {
let has_more = (data.offset + data.limit) < data.total_count as u32;
let (total_pages, current_page) = if data.limit > 0 {
let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
let tp = data.total_count.div_ceil(data.limit as u64) as u32;
(tp, data.offset / data.limit)
} else {
(0, 0)
@@ -420,16 +436,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
let limit = data.limit;
let total_pages = if limit > 0 {
((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32
} else {
0
};
let current_page = if limit > 0 {
data.current_offset / limit
} else {
0
};
let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32;
let current_page = data.current_offset.checked_div(limit).unwrap_or(0);
ActivityFeedTemplate {
entries: &data.entries.items,
current_offset: data.current_offset,
@@ -496,7 +504,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
let heatmap = data
.history
.as_deref()
.map(|h| build_heatmap(h))
.map(build_heatmap)
.unwrap_or_default();
let profile_display_name = data
.profile_user_email
@@ -521,18 +529,10 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.entries
.as_ref()
.map(|e| {
if e.limit > 0 {
((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32
} else {
0
}
e.total_count.div_ceil(e.limit.max(1) as u64) as u32
})
.unwrap_or(0);
let current_page = if data.limit > 0 {
data.current_offset / data.limit
} else {
0
};
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
let avg_rating_display = data
.stats
.avg_rating
@@ -594,7 +594,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
ctx: &data.ctx,
movie: &data.movie,
stats: &data.stats,
profile: data.profile.as_ref(),
reviews: &data.reviews.items,
on_watchlist: data.on_watchlist,
current_offset: data.current_offset,
has_more: data.has_more,
limit: data.limit,
@@ -604,6 +606,21 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.map_err(|e| e.to_string())
}
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String> {
WatchlistTemplate {
ctx: &data.ctx,
owner_id: data.owner_id,
display_entries: &data.display_entries,
current_offset: data.current_offset,
has_more: data.has_more,
limit: data.limit,
is_owner: data.is_owner,
error: data.error,
}
.render()
.map_err(|e| e.to_string())
}
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String> {
FollowingTemplate {
ctx: data.ctx,

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ pub mod movies;
pub mod search;
pub mod social;
pub mod users;
pub mod watchlist;
pub use auth::*;
pub use common::*;
@@ -14,3 +15,4 @@ pub use import::*;
pub use movies::*;
pub use social::*;
pub use users::*;
pub use watchlist::*;

View File

@@ -10,6 +10,10 @@ pub struct MoviesQueryParams {
pub offset: Option<u32>,
/// Optional title filter (case-insensitive substring match)
pub search: Option<String>,
/// Filter by genre name (case-insensitive)
pub genre: Option<String>,
/// Filter by ISO language code (e.g. "en")
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -79,6 +83,11 @@ pub struct MovieDto {
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Vec<String>,
pub runtime_minutes: Option<u32>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::movies::MovieDto;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WatchlistEntryDto {
pub id: Uuid,
pub movie: MovieDto,
pub added_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct WatchlistResponse {
pub items: Vec<WatchlistEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct AddToWatchlistRequest {
pub movie_id: Uuid,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct WatchlistStatusResponse {
pub on_watchlist: bool,
}

View File

@@ -14,6 +14,7 @@ tokio = { workspace = true }
[features]
xlsx = []
federation = []
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -2,14 +2,17 @@ use chrono::NaiveDateTime;
use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid;
pub struct LogReviewCommand {
pub struct MovieInput {
pub movie_id: Option<Uuid>,
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
}
pub struct LogReviewCommand {
pub user_id: Uuid,
pub input: MovieInput,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
@@ -81,3 +84,13 @@ pub struct EnrichMovieCommand {
pub movie_id: domain::value_objects::MovieId,
pub profile: domain::models::MovieProfile,
}
pub struct AddToWatchlistCommand {
pub user_id: Uuid,
pub input: MovieInput,
}
pub struct RemoveFromWatchlistCommand {
pub user_id: Uuid,
pub movie_id: Uuid,
}

View File

@@ -7,7 +7,10 @@ use domain::ports::{
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
PersonCommand, PersonQuery, SearchCommand, SearchPort,
ReviewRepository, StatsRepository, UserRepository,
WatchlistRepository,
};
#[cfg(feature = "federation")]
use domain::ports::RemoteWatchlistRepository;
use crate::config::AppConfig;
@@ -33,5 +36,8 @@ pub struct AppContext {
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub watchlist_repository: Arc<dyn WatchlistRepository>,
#[cfg(feature = "federation")]
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
pub config: AppConfig,
}

View File

@@ -6,7 +6,7 @@ use domain::{
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
};
use crate::commands::LogReviewCommand;
use crate::commands::MovieInput;
pub struct MovieResolverDeps<'a> {
pub repository: &'a dyn MovieRepository,
@@ -15,10 +15,10 @@ pub struct MovieResolverDeps<'a> {
#[async_trait]
pub trait ResolutionStrategy: Send + Sync {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool;
fn can_handle(&self, input: &MovieInput) -> bool;
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError>;
}
@@ -44,15 +44,14 @@ impl MovieResolver {
pub async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies {
if strategy.can_handle(cmd) {
if let Some(result) = strategy.resolve(cmd, deps).await? {
if strategy.can_handle(input)
&& let Some(result) = strategy.resolve(input, deps).await? {
return Ok(result);
}
}
}
Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(),
@@ -62,16 +61,16 @@ impl MovieResolver {
#[async_trait]
impl ResolutionStrategy for ExternalIdStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.external_metadata_id.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.external_metadata_id.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let ext_id_str = cmd.external_metadata_id.as_deref().unwrap();
let ext_id_str = input.external_metadata_id.as_deref().unwrap();
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
@@ -97,22 +96,30 @@ impl ResolutionStrategy for ExternalIdStrategy {
#[async_trait]
impl ResolutionStrategy for TitleSearchStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title = cmd.manual_title.as_deref().unwrap();
let title = input.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title {
title: MovieTitle::new(title.to_string())?,
year: cmd.manual_release_year.map(ReleaseYear::new).transpose()?,
year: input.manual_release_year.map(ReleaseYear::new).transpose()?,
};
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => Ok(Some((m, true))),
Ok(m) => {
// Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id() {
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? {
return Ok(Some((existing, false)));
}
}
Ok(Some((m, true)))
}
Err(e) => {
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
Ok(None)
@@ -123,20 +130,20 @@ impl ResolutionStrategy for TitleSearchStrategy {
#[async_trait]
impl ResolutionStrategy for ManualMovieStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
fn can_handle(&self, input: &MovieInput) -> bool {
input.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
input: &MovieInput,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title_str = match &cmd.manual_title {
let title_str = match &input.manual_title {
Some(t) => t,
None => return Ok(None),
};
let year_val = cmd.manual_release_year.ok_or_else(|| {
let year_val = input.manual_release_year.ok_or_else(|| {
DomainError::ValidationError(
"Manual release year required if TMDB fetch fails or is omitted".into(),
)
@@ -152,13 +159,13 @@ impl ResolutionStrategy for ManualMovieStrategy {
let matched = candidates
.into_iter()
.find(|m| m.is_manual_match(&title, &release_year, cmd.manual_director.as_deref()));
.find(|m| m.is_manual_match(&title, &release_year, input.manual_director.as_deref()));
if let Some(existing) = matched {
Ok(Some((existing, false)))
} else {
let new_movie =
Movie::new(None, title, release_year, cmd.manual_director.clone(), None);
Movie::new(None, title, release_year, input.manual_director.clone(), None);
Ok(Some((new_movie, true)))
}
}

View File

@@ -1,8 +1,8 @@
use uuid::Uuid;
use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, Movie, MovieStats, UserStats, UserSummary, UserTrends,
collections::Paginated,
DiaryEntry, FeedEntry, MonthActivity, Movie, MovieProfile, MovieStats, UserStats, UserSummary,
UserTrends, collections::Paginated,
};
pub struct RemoteActorView {
@@ -102,12 +102,38 @@ pub struct MovieDetailPageData {
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub profile: Option<MovieProfile>,
pub on_watchlist: bool,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub histogram_max: u64,
}
#[derive(Clone, Debug)]
pub struct WatchlistDisplayEntry {
/// Always a full URL: /images/{path} for local, https://... for remote
pub poster_url: Option<String>,
pub movie_title: String,
pub release_year: u16,
/// /movies/{id} for local; None for remote entries without a local movie record
pub movie_url: Option<String>,
pub added_at: String,
/// /watchlist/{movie_id}/remove for owner; None for remote or non-owner
pub remove_url: Option<String>,
}
pub struct WatchlistPageData {
pub ctx: HtmlPageContext,
pub owner_id: uuid::Uuid,
pub display_entries: Vec<WatchlistDisplayEntry>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub is_owner: bool,
pub error: Option<String>,
}
pub struct ImportUploadPageData {
pub ctx: HtmlPageContext,
pub profiles: Vec<ImportProfileView>,
@@ -201,6 +227,7 @@ pub trait HtmlRenderer: Send + Sync {
) -> Result<String, String>;
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync {

View File

@@ -85,4 +85,17 @@ pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
pub genre: Option<String>,
pub language: Option<String>,
}
pub struct GetWatchlistQuery {
pub user_id: Uuid,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub struct IsOnWatchlistQuery {
pub user_id: Uuid,
pub movie_id: Uuid,
}

View File

@@ -1,5 +1,5 @@
use super::*;
use chrono::NaiveDate;
use crate::commands::MovieInput;
use domain::{
errors::DomainError,
models::Movie,
@@ -7,19 +7,13 @@ use domain::{
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
};
fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> LogReviewCommand {
LogReviewCommand {
fn make_input(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> MovieInput {
MovieInput {
movie_id: None,
external_metadata_id: ext_id.map(String::from),
manual_title: title.map(String::from),
manual_release_year: year,
manual_director: None,
user_id: uuid::Uuid::new_v4(),
rating: 4,
comment: None,
watched_at: NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
}
}
@@ -59,7 +53,7 @@ impl MovieRepository for RepoWithExternalMovie {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
#[async_trait::async_trait]
@@ -82,7 +76,7 @@ impl MovieRepository for RepoEmpty {
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
#[async_trait::async_trait]
@@ -105,7 +99,7 @@ impl MovieRepository for RepoWithTitleMatch {
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> { panic!("unexpected") }
}
struct MetaReturnsMovie(Movie);
@@ -149,14 +143,14 @@ impl MetadataClient for MetaErrors {
#[test]
fn external_id_strategy_can_handle_cmd_with_id() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&input));
}
#[test]
fn external_id_strategy_cannot_handle_cmd_without_id() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&input));
}
#[tokio::test]
@@ -168,8 +162,8 @@ async fn external_id_strategy_returns_cached_movie() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
@@ -182,8 +176,8 @@ async fn external_id_strategy_fetches_from_metadata_when_not_cached() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -195,8 +189,8 @@ async fn external_id_strategy_falls_through_on_metadata_error() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
@@ -204,14 +198,14 @@ async fn external_id_strategy_falls_through_on_metadata_error() {
#[test]
fn title_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&input));
}
#[test]
fn title_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&input));
}
#[tokio::test]
@@ -223,8 +217,8 @@ async fn title_strategy_fetches_from_metadata() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -236,8 +230,8 @@ async fn title_strategy_falls_through_on_metadata_error() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&input, &deps).await.unwrap();
assert!(result.is_none());
}
@@ -245,14 +239,14 @@ async fn title_strategy_falls_through_on_metadata_error() {
#[test]
fn manual_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&cmd));
let input = make_input(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&input));
}
#[test]
fn manual_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&cmd));
let input = make_input(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&input));
}
#[tokio::test]
@@ -264,8 +258,8 @@ async fn manual_strategy_returns_existing_movie() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
@@ -277,8 +271,8 @@ async fn manual_strategy_creates_new_movie_when_no_match() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
let input = make_input(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&input, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
@@ -290,8 +284,8 @@ async fn manual_strategy_errors_without_year() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&cmd, &deps).await.is_err());
let input = make_input(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&input, &deps).await.is_err());
}
// --- MovieResolver pipeline ---
@@ -304,8 +298,8 @@ async fn resolver_returns_error_when_no_strategy_matches() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&cmd, &deps).await;
let input = make_input(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&input, &deps).await;
assert!(result.is_err());
}
@@ -318,9 +312,9 @@ async fn resolver_uses_cached_movie_when_external_id_matches() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let input = make_input(Some("tt123"), None, None);
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.resolve(&input, &deps)
.await
.unwrap();
assert!(!is_new);
@@ -334,9 +328,9 @@ async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), Some("Inception"), Some(2010));
let input = make_input(Some("tt123"), Some("Inception"), Some(2010));
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.resolve(&input, &deps)
.await
.unwrap();
assert!(is_new);

View File

@@ -45,6 +45,7 @@ impl EventHandler for RecordingHandler {
DomainEvent::UserUpdated { .. } => "user_updated",
DomainEvent::MovieEnrichmentRequested { .. } => "movie_enrichment_requested",
DomainEvent::ImageStored { .. } => "image_stored",
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => "watchlist",
};
self.calls.lock().unwrap().push(label);
Ok(())

View File

@@ -0,0 +1,56 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::WatchlistEntry,
value_objects::{MovieId, UserId},
};
use crate::{
commands::AddToWatchlistCommand,
context::AppContext,
movie_resolver::{MovieResolver, MovieResolverDeps},
};
pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
ctx.movie_repository
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?
} else {
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
let (movie, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?;
if is_new {
ctx.movie_repository.upsert_movie(&movie).await?;
if let Some(ext_id) = movie.external_metadata_id() {
let _ = ctx.event_publisher.publish(&DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
}).await;
}
}
movie
};
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
ctx.watchlist_repository.add(&entry).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
}).await;
Ok(())
}

View File

@@ -36,13 +36,11 @@ pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result
}
async fn check_duplicate(ctx: &AppContext, row: &domain::models::ImportRow) -> Result<bool, DomainError> {
if let Some(ext_id) = &row.external_metadata_id {
if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) {
if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {
if let Some(ext_id) = &row.external_metadata_id
&& let Ok(eid) = ExternalMetadataId::new(ext_id.clone())
&& ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() {
return Ok(true);
}
}
}
if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) {
let title_vo = MovieTitle::new(title.clone());
let year_vo = year_str.parse::<u16>().ok().and_then(|y| ReleaseYear::new(y).ok());

View File

@@ -6,7 +6,7 @@ use domain::{
};
use uuid::Uuid;
use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review};
use crate::{commands::{ExecuteImportCommand, LogReviewCommand, MovieInput}, context::AppContext, use_cases::log_review};
pub struct ImportSummary {
pub imported: usize,
@@ -71,11 +71,14 @@ fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result<LogReviewCommand, St
.map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?;
Ok(LogReviewCommand {
external_metadata_id: row.external_metadata_id.clone(),
manual_title: row.title.clone(),
manual_release_year: row.release_year.as_deref().and_then(|s| s.parse().ok()),
manual_director: row.director.clone(),
user_id,
input: MovieInput {
movie_id: None,
external_metadata_id: row.external_metadata_id.clone(),
manual_title: row.title.clone(),
manual_release_year: row.release_year.as_deref().and_then(|s| s.parse().ok()),
manual_director: row.director.clone(),
},
rating,
comment: row.comment.clone(),
watched_at,

View File

@@ -1,6 +1,6 @@
use domain::{
errors::DomainError,
models::{FeedEntry, Movie, MovieStats, collections::{PageParams, Paginated}},
models::{FeedEntry, Movie, MovieProfile, MovieStats, collections::{PageParams, Paginated}},
value_objects::MovieId,
};
@@ -10,6 +10,7 @@ pub struct MovieSocialPageResult {
pub movie: Movie,
pub stats: MovieStats,
pub reviews: Paginated<FeedEntry>,
pub profile: Option<MovieProfile>,
}
pub async fn execute(
@@ -25,10 +26,11 @@ pub async fn execute(
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
let (stats, reviews) = tokio::try_join!(
let (stats, reviews, profile) = tokio::try_join!(
ctx.diary_repository.get_movie_stats(&movie_id),
ctx.diary_repository.get_movie_social_feed(&movie_id, &page),
ctx.movie_profile_repository.get_by_movie_id(&movie_id),
)?;
Ok(MovieSocialPageResult { movie, stats, reviews })
Ok(MovieSocialPageResult { movie, stats, reviews, profile })
}

View File

@@ -1,14 +1,17 @@
use domain::{
errors::DomainError,
models::collections::{PageParams, Paginated},
models::Movie,
models::{MovieFilter, MovieSummary},
};
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<Movie>, DomainError> {
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<MovieSummary>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
ctx.movie_repository
.list_movies(&page, query.search.as_deref())
.await
let filter = MovieFilter {
search: query.search,
genre: query.genre,
language: query.language,
};
ctx.movie_repository.list_movies(&page, &filter).await
}

View File

@@ -0,0 +1,16 @@
use domain::{
errors::DomainError,
models::{WatchlistWithMovie, collections::{PageParams, Paginated}},
value_objects::UserId,
};
use crate::{context::AppContext, queries::GetWatchlistQuery};
pub async fn execute(
ctx: &AppContext,
query: GetWatchlistQuery,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let page = PageParams::new(query.limit, query.offset)?;
ctx.watchlist_repository.get_for_user(&user_id, &page).await
}

View File

@@ -0,0 +1,12 @@
use domain::{
errors::DomainError,
value_objects::{MovieId, UserId},
};
use crate::{context::AppContext, queries::IsOnWatchlistQuery};
pub async fn execute(ctx: &AppContext, query: IsOnWatchlistQuery) -> Result<bool, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let movie_id = MovieId::from_uuid(query.movie_id);
ctx.watchlist_repository.contains(&user_id, &movie_id).await
}

View File

@@ -2,7 +2,7 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
value_objects::{Comment, Rating, UserId},
value_objects::{Comment, MovieId, Rating, UserId},
};
use crate::{
@@ -16,19 +16,39 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
let (movie, is_new_movie) = if let Some(id) = cmd.input.movie_id {
let movie_id = MovieId::from_uuid(id);
let movie = ctx
.movie_repository
.get_movie_by_id(&movie_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {id}")))?;
(movie, false)
} else {
let deps = MovieResolverDeps {
repository: ctx.movie_repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
MovieResolver::default_pipeline()
.resolve(&cmd.input, &deps)
.await?
};
let (movie, is_new_movie) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await?;
ctx.movie_repository.upsert_movie(&movie).await?;
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review_event = ctx.review_repository.save_review(&review).await?;
let was_on_watchlist = ctx.watchlist_repository
.remove_if_present(review.user_id(), review.movie_id())
.await?;
if was_on_watchlist {
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
}).await;
}
publish_events(ctx, &movie, is_new_movie, review_event).await?;
Ok(())
@@ -40,15 +60,14 @@ async fn publish_events(
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie {
if let Some(ext_id) = movie.external_metadata_id() {
if is_new_movie
&& let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.event_publisher.publish(&discovery_event).await?;
}
}
if let Some(ext_id) = movie.external_metadata_id() {
let enrichment_event = DomainEvent::MovieEnrichmentRequested {

View File

@@ -24,3 +24,7 @@ pub mod register;
pub mod search;
pub mod sync_poster;
pub mod update_profile;
pub mod add_to_watchlist;
pub mod remove_from_watchlist;
pub mod get_watchlist;
pub mod is_on_watchlist;

View File

@@ -0,0 +1,20 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{MovieId, UserId},
};
use crate::{commands::RemoveFromWatchlistCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: RemoveFromWatchlistCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie_id = MovieId::from_uuid(cmd.movie_id);
ctx.watchlist_repository.remove(&user_id, &movie_id).await?;
let _ = ctx.event_publisher.publish(&DomainEvent::WatchlistEntryRemoved {
user_id,
movie_id,
}).await;
Ok(())
}

View File

@@ -44,6 +44,18 @@ pub enum DomainEvent {
ImageStored {
key: String,
},
WatchlistEntryAdded {
user_id: UserId,
movie_id: MovieId,
movie_title: String,
release_year: u16,
external_metadata_id: Option<String>,
added_at: chrono::NaiveDateTime,
},
WatchlistEntryRemoved {
user_id: UserId,
movie_id: MovieId,
},
}
#[async_trait]

View File

@@ -14,6 +14,10 @@ pub mod import_session;
pub mod import_profile;
pub mod person;
pub mod search;
pub mod watchlist;
pub use watchlist::{WatchlistEntry, WatchlistWithMovie};
pub mod remote_watchlist;
pub use remote_watchlist::RemoteWatchlistEntry;
pub use import::{
AnnotatedRow, DomainField, FieldMapping, FileFormat, ImportError,
@@ -45,6 +49,23 @@ pub struct DiaryFilter {
pub search: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct MovieFilter {
pub search: Option<String>,
pub genre: Option<String>,
pub language: Option<String>,
}
#[derive(Clone, Debug)]
pub struct MovieSummary {
pub movie: Movie,
pub genres: Vec<String>,
pub runtime_minutes: Option<u32>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct Movie {
id: MovieId,
@@ -133,18 +154,13 @@ impl Movie {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum ReviewSource {
#[default]
Local,
Remote { actor_url: String },
}
impl Default for ReviewSource {
fn default() -> Self {
ReviewSource::Local
}
}
#[derive(Clone, Debug)]
pub struct Review {
id: ReviewId,

View File

@@ -0,0 +1,12 @@
use chrono::{DateTime, Utc};
#[derive(Clone, Debug)]
pub struct RemoteWatchlistEntry {
pub ap_id: String,
pub actor_url: String,
pub movie_title: String,
pub release_year: u16,
pub external_metadata_id: Option<String>,
pub poster_url: Option<String>,
pub added_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,31 @@
use chrono::{NaiveDateTime, Utc};
use crate::{
models::Movie,
value_objects::{MovieId, UserId, WatchlistEntryId},
};
#[derive(Clone, Debug)]
pub struct WatchlistEntry {
pub id: WatchlistEntryId,
pub user_id: UserId,
pub movie_id: MovieId,
pub added_at: NaiveDateTime,
}
impl WatchlistEntry {
pub fn new(user_id: UserId, movie_id: MovieId) -> Self {
Self {
id: WatchlistEntryId::generate(),
user_id,
movie_id,
added_at: Utc::now().naive_utc(),
}
}
}
#[derive(Clone, Debug)]
pub struct WatchlistWithMovie {
pub entry: WatchlistEntry,
pub movie: Movie,
}

View File

@@ -6,10 +6,10 @@ use crate::{
events::{DomainEvent, EventEnvelope},
models::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
EntityType, ExternalPersonId, IndexableDocument, Person, PersonCredits,
PersonId, SearchQuery, SearchResults,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
MovieStats, MovieSummary, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary,
UserTrends, WatchlistEntry, WatchlistWithMovie, RemoteWatchlistEntry, EntityType, ExternalPersonId,
IndexableDocument, Person, PersonCredits, PersonId, SearchQuery, SearchResults,
collections::{self, PageParams, Paginated},
},
value_objects::{
@@ -88,8 +88,8 @@ pub trait MovieRepository: Send + Sync {
async fn list_movies(
&self,
page: &collections::PageParams,
search: Option<&str>,
) -> Result<collections::Paginated<Movie>, DomainError>;
filter: &MovieFilter,
) -> Result<collections::Paginated<MovieSummary>, DomainError>;
}
#[async_trait]
@@ -310,3 +310,41 @@ pub trait SearchCommand: Send + Sync {
/// Remove a document from the search index by entity type and internal ID string.
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
}
#[async_trait]
pub trait WatchlistRepository: Send + Sync {
/// Add a new entry. Silently succeeds if the entry already exists.
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
/// Remove an entry. Returns NotFound if the entry does not exist.
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
/// Remove an entry if it exists. Never returns NotFound.
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
) -> Result<bool, DomainError>;
async fn get_for_user(
&self,
user_id: &UserId,
page: &collections::PageParams,
) -> Result<collections::Paginated<WatchlistWithMovie>, DomainError>;
async fn contains(
&self,
user_id: &UserId,
movie_id: &MovieId,
) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait RemoteWatchlistRepository: Send + Sync {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError>;
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError>;
/// Find entries for a remote actor whose URL hashes (v5 UUID) to the given UUID.
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
}

View File

@@ -25,6 +25,7 @@ uuid_id!(ReviewId);
uuid_id!(UserId);
uuid_id!(ImportSessionId);
uuid_id!(ImportProfileId);
uuid_id!(WatchlistEntryId);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMetadataId(String);
@@ -80,7 +81,7 @@ impl MovieTitle {
))
} else if trimmed.len() > Self::MAX_LENGTH {
Err(DomainError::ValidationError(
format!("Movie title exceeds {} characters", Self::MAX_LENGTH).into(),
format!("Movie title exceeds {} characters", Self::MAX_LENGTH),
))
} else {
Ok(Self(trimmed.to_string()))
@@ -102,7 +103,7 @@ impl Comment {
let trimmed = comment.trim();
if trimmed.len() > Self::MAX_LENGTH {
Err(DomainError::ValidationError(
format!("Comment exceeds {} characters", Self::MAX_LENGTH).into(),
format!("Comment exceeds {} characters", Self::MAX_LENGTH),
))
} else {
Ok(Self(trimmed.to_string()))
@@ -190,8 +191,7 @@ impl Username {
"Username must be {}{} characters",
Self::MIN_LENGTH,
Self::MAX_LENGTH
)
.into(),
),
));
}
if !s

View File

@@ -9,7 +9,7 @@ sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = []
federation = ["application/federation"]
sqlite-federation = [
"sqlite",
"dep:sqlite-federation",

View File

@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
use serde::Deserialize;
use uuid::Uuid;
use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery};
use domain::{errors::DomainError, models::SortDirection};
use api_types::{DiaryQueryParams, LogReviewRequest};
@@ -124,6 +124,25 @@ pub struct ActorUrlForm {
pub csrf_token: String,
}
#[derive(serde::Deserialize)]
pub struct WatchlistAddForm {
pub movie_id: Option<uuid::Uuid>,
pub query: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<u16>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
#[serde(default)]
pub redirect_after: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct WatchlistQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
@@ -206,14 +225,17 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
impl LogReviewData {
pub fn into_command(self, user_id: Uuid) -> LogReviewCommand {
LogReviewCommand {
external_metadata_id: self.external_metadata_id,
manual_title: self.manual_title,
manual_release_year: self.manual_release_year,
manual_director: self.manual_director,
user_id,
input: MovieInput {
movie_id: None,
external_metadata_id: self.external_metadata_id,
manual_title: self.manual_title,
manual_release_year: self.manual_release_year,
manual_director: self.manual_director,
},
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
user_id,
}
}
}

View File

@@ -10,11 +10,13 @@ use std::str::FromStr;
use application::{
commands::{
DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
AddToWatchlistCommand, RemoveFromWatchlistCommand,
},
queries::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
GetWatchlistQuery, IsOnWatchlistQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
@@ -22,11 +24,12 @@ use application::{
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile,
search as search_uc, get_person, get_person_credits,
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
},
};
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams},
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
services::review_history::Trend,
value_objects::{MovieId, UserId},
};
@@ -44,6 +47,7 @@ use api_types::{
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
@@ -96,12 +100,14 @@ pub async fn list_movies(
limit: params.limit,
offset: params.offset,
search: params.search,
genre: params.genre,
language: params.language,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page.items.iter().map(movie_to_dto).collect(),
items: page.items.iter().map(summary_to_dto).collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
@@ -438,12 +444,11 @@ pub async fn update_profile_handler(
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
@@ -476,6 +481,26 @@ fn movie_to_dto(movie: &Movie) -> MovieDto {
release_year: movie.release_year().value(),
director: movie.director().map(|d| d.to_string()),
poster_path: movie.poster_path().map(|p| p.value().to_string()),
genres: vec![],
runtime_minutes: None,
original_language: None,
overview: None,
collection_name: None,
}
}
fn summary_to_dto(summary: &MovieSummary) -> MovieDto {
MovieDto {
id: summary.movie.id().value(),
title: summary.movie.title().value().to_string(),
release_year: summary.movie.release_year().value(),
director: summary.movie.director().map(|d| d.to_string()),
poster_path: summary.movie.poster_path().map(|p| p.value().to_string()),
genres: summary.genres.clone(),
runtime_minutes: summary.runtime_minutes,
original_language: summary.original_language.clone(),
overview: summary.overview.clone(),
collection_name: summary.collection_name.clone(),
}
}
@@ -1233,3 +1258,119 @@ pub async fn get_person_credits_handler(
}
}
}
#[utoipa::path(
get, path = "/api/v1/watchlist",
params(
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Offset"),
),
responses(
(status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_handler(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery {
user_id: user.0.value(),
limit: params.limit,
offset: params.offset,
},
)
.await?;
Ok(Json(WatchlistResponse {
items: page.items.into_iter().map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
}).collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/watchlist",
request_body = AddToWatchlistRequest,
responses(
(status = 201, description = "Added to watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Movie not found"),
),
security(("bearer_auth" = []))
)]
pub async fn post_watchlist_add(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
add_to_watchlist::execute(
&state.app_ctx,
AddToWatchlistCommand {
user_id: user.0.value(),
input: MovieInput {
movie_id: Some(req.movie_id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
},
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Removed from watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not on watchlist"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_watchlist_entry(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id },
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = WatchlistStatusResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_status(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: user.0.value(), movie_id },
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}

View File

@@ -15,15 +15,17 @@ use application::ports::{
FollowersPageData, FollowingPageData,
};
use application::{
commands::{DeleteReviewCommand, RegisterCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, LoginQuery},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc, update_profile,
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile,
},
};
use domain::models::ExportFormat;
@@ -383,12 +385,11 @@ pub async fn get_activity_feed(
let mut local_ids = vec![uid.value()];
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
local_ids.push(parsed_id);
continue;
}
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
@@ -953,7 +954,7 @@ pub async fn get_movie_detail(
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
@@ -973,10 +974,19 @@ pub async fn get_movie_detail(
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
let has_more = result.reviews.offset + result.reviews.limit
< result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => state.app_ctx.watchlist_repository
.contains(uid, &domain::value_objects::MovieId::from_uuid(movie_id))
.await
.unwrap_or(false),
None => false,
};
let data = MovieDetailPageData {
ctx,
movie: result.movie,
stats: result.stats,
profile: result.profile,
on_watchlist,
current_offset: result.reviews.offset,
has_more,
limit: result.reviews.limit,
@@ -994,6 +1004,206 @@ pub async fn get_movie_detail(
}
}
pub async fn get_watchlist_page(
OptionalCookieUser(viewer_id): OptionalCookieUser,
State(state): State<AppState>,
Path(owner_id): Path<uuid::Uuid>,
Query(params): Query<crate::forms::WatchlistQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
// Try local user first
let local_user = state.app_ctx.user_repository
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
.await
.ok()
.flatten();
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
match get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) },
).await {
Err(e) => {
tracing::error!("watchlist error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Ok(entries) => {
let has_more = entries.offset + entries.limit < entries.total_count as u32;
let display: Vec<WatchlistDisplayEntry> = entries.items.iter().map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w.movie.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
}).collect();
(display, has_more, entries.offset, entries.limit)
}
}
} else {
#[cfg(feature = "federation")]
{
let remote_entries = state.app_ctx.remote_watchlist_repository
.get_by_derived_uuid(owner_id)
.await
.unwrap_or_default();
let display: Vec<WatchlistDisplayEntry> = remote_entries.into_iter().map(|e| {
WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
}
}).collect();
let len = display.len() as u32;
(display, false, 0u32, len)
}
#[cfg(not(feature = "federation"))]
{
(vec![], false, 0u32, 0u32)
}
};
let data = WatchlistPageData {
ctx,
owner_id,
display_entries,
current_offset,
has_more,
limit: page_limit,
is_owner,
error: params.error,
};
match state.html_renderer.render_watchlist_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("watchlist template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_watchlist_add(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::WatchlistAddForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let redirect_base = form
.redirect_after
.as_deref()
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or("/")
.to_string();
let input = if let Some(id) = form.movie_id {
MovieInput {
movie_id: Some(id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
let query = form.query.as_deref().unwrap_or("").trim().to_string();
let is_external_id = query.starts_with("tmdb:")
|| (query.starts_with("tt")
&& query.len() > 2
&& query[2..].chars().all(|c| c.is_ascii_digit()));
if is_external_id {
MovieInput {
movie_id: None,
external_metadata_id: Some(query),
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
MovieInput {
movie_id: None,
external_metadata_id: None,
manual_title: if query.is_empty() { None } else { Some(query) },
manual_release_year: form.year,
manual_director: None,
}
}
};
match add_to_watchlist::execute(
&state.app_ctx,
AddToWatchlistCommand {
user_id: user_id.value(),
input,
},
)
.await
{
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::ValidationError(msg)) => {
let sep = if redirect_base.contains('?') { '&' } else { '?' };
let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
Redirect::to(&url).into_response()
}
Err(e) => {
tracing::error!("watchlist add error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_watchlist_remove(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(movie_id): Path<uuid::Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand {
user_id: user_id.value(),
movie_id,
},
)
.await
{
Ok(()) | Err(DomainError::NotFound(_)) => {
let redirect_url = form
.redirect_after
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(e) => {
tracing::error!("watchlist remove error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
@@ -1219,12 +1429,11 @@ pub async fn post_profile_settings(
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}

View File

@@ -15,8 +15,6 @@ use presentation::{openapi, routes, state::AppState};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
#[cfg(feature = "sqlite")]
use sqlite_search;
#[cfg(feature = "postgres")]
use postgres_search;
@@ -54,21 +52,21 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, person_command, person_query, search_command, search_port, db_pool) =
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, watchlist_repository, person_command, person_query, search_command, search_port, db_pool) =
match backend.as_str() {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp) = postgres::wire(&database_url).await?;
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?;
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Postgres(pool))
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Postgres(pool))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp) = sqlite::wire(&database_url).await?;
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, pc, pq, sc, sp, DbPool::Sqlite(pool))
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Sqlite(pool))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
@@ -78,8 +76,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let event_bus = EventBusBackend::from_env()?;
#[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query) = {
let (federation_repo, social_query_arc, review_store) = match &db_pool {
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool {
#[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
@@ -91,6 +89,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let ap = activitypub::wire(
federation_repo,
review_store,
remote_watchlist_repo.clone(),
Arc::clone(&watchlist_repository),
Arc::clone(&user_repository),
Arc::clone(&movie_repository),
Arc::clone(&review_repository),
@@ -123,7 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
nats::create_publisher(cfg).await?
}
};
(ep, ap_router, ap_service_arc, social_query_arc)
(ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo)
};
#[cfg(not(feature = "federation"))]
@@ -171,6 +171,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
movie_profile_repository,
watchlist_repository,
#[cfg(feature = "federation")]
remote_watchlist_repository: remote_watchlist_repo,
person_command,
person_query,
search_port,

View File

@@ -5,6 +5,7 @@ mod movies;
mod search;
mod social;
mod users;
mod watchlist;
use axum::Router;
use utoipa::{
@@ -38,6 +39,7 @@ fn build() -> utoipa::openapi::OpenApi {
api.merge(users::UsersDoc::openapi());
api.merge(import::ImportDoc::openapi());
api.merge(search::SearchDoc::openapi());
api.merge(watchlist::WatchlistDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);

View File

@@ -0,0 +1,19 @@
use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_watchlist_handler,
crate::handlers::api::post_watchlist_add,
crate::handlers::api::delete_watchlist_entry,
crate::handlers::api::get_watchlist_status,
),
components(schemas(
WatchlistResponse,
WatchlistEntryDto,
AddToWatchlistRequest,
WatchlistStatusResponse,
))
)]
pub struct WatchlistDoc;

View File

@@ -107,7 +107,19 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
routing::get(handlers::html::get_profile_settings)
.post(handlers::html::post_profile_settings),
)
.route("/tags/{tag}", routing::get(handlers::html::get_tag));
.route("/tags/{tag}", routing::get(handlers::html::get_tag))
.route(
"/users/{id}/watchlist",
routing::get(handlers::html::get_watchlist_page),
)
.route(
"/watchlist/add",
routing::post(handlers::html::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}/remove",
routing::post(handlers::html::post_watchlist_remove),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_html_routes());
@@ -213,7 +225,17 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
.route("/search", routing::get(handlers::api::get_search))
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler));
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))
.route(
"/watchlist",
routing::get(handlers::api::get_watchlist_handler)
.post(handlers::api::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}",
routing::get(handlers::api::get_watchlist_status)
.delete(handlers::api::delete_watchlist_entry),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());

View File

@@ -143,3 +143,48 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let app = Router::new()
.route("/api/v1/watchlist", get(crate::handlers::api::get_watchlist_handler))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(crate::handlers::api::get_watchlist_status),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}

View File

@@ -19,7 +19,7 @@ use domain::{
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
StatsRepository, UserRepository, WatchlistRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
@@ -58,7 +58,7 @@ impl MovieRepository for Panic {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!()
}
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> {
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
panic!()
}
}
@@ -242,6 +242,14 @@ impl domain::ports::ImportProfileRepository for Panic {
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
}
#[async_trait::async_trait]
impl WatchlistRepository for Panic {
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() }
async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::WatchlistWithMovie>, DomainError> { panic!() }
async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
}
#[async_trait::async_trait]
impl domain::ports::MovieProfileRepository for Panic {
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
@@ -335,6 +343,7 @@ impl crate::ports::HtmlRenderer for Panic {
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
fn render_watchlist_page(&self, _: application::ports::WatchlistPageData) -> Result<String, String> { panic!() }
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
@@ -373,6 +382,15 @@ impl SearchCommand for Panic {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::RemoteWatchlistRepository for Panic {
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) }
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
}
// --- Single state factory — only auth_service varies ---
@@ -395,6 +413,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
import_session_repository: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _,
watchlist_repository: Arc::clone(&repo) as _,
#[cfg(feature = "federation")]
remote_watchlist_repository: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,

View File

@@ -1,45 +1,10 @@
// Re-export imports needed by subtest modules
pub use application::{config::AppConfig, context::AppContext};
pub use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
pub use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
pub use std::sync::Arc;
pub use tower::ServiceExt;
// API types for tests
pub use api_types::{
LoginRequest, LogReviewRequest, DiaryQueryParams,
};
pub use crate::{
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
forms::{LogReviewData, LogReviewForm, to_diary_query},
state::AppState,
};
pub use api_types::{DiaryQueryParams, LogReviewRequest};
mod api_handlers;
mod extractors;
mod forms;
mod api_handlers;

View File

@@ -164,6 +164,16 @@ impl domain::ports::ImportProfileRepository for PanicImportProfile {
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
}
struct PanicWatchlist;
#[async_trait]
impl domain::ports::WatchlistRepository for PanicWatchlist {
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() }
async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::WatchlistWithMovie>, DomainError> { panic!() }
async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
}
struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
@@ -194,6 +204,18 @@ impl SearchCommand for PanicSearchCommand {
#[cfg(feature = "federation")]
struct PanicSocialQuery;
#[cfg(feature = "federation")]
struct PanicRemoteWatchlist;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist {
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) }
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for PanicSocialQuery {
@@ -236,6 +258,9 @@ async fn test_app() -> Router {
import_session_repository: Arc::new(PanicImportSession),
import_profile_repository: Arc::new(PanicImportProfile),
movie_profile_repository: Arc::new(PanicMovieProfile),
watchlist_repository: Arc::new(PanicWatchlist),
#[cfg(feature = "federation")]
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),

View File

@@ -644,23 +644,21 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
}
Action::ScrollUp => {
if let Screen::Main(m) = &mut app.screen {
if m.diary.selected > 0 {
if let Screen::Main(m) = &mut app.screen
&& m.diary.selected > 0 {
m.diary.selected -= 1;
m.diary.history = None;
}
}
vec![]
}
Action::OpenHistory => {
if let Screen::Main(m) = &mut app.screen {
if let Some(entry) = m.diary.entries.get(m.diary.selected) {
if let Screen::Main(m) = &mut app.screen
&& let Some(entry) = m.diary.entries.get(m.diary.selected) {
let movie_id = entry.movie.id;
app.loading = true;
return vec![Command::LoadHistory { movie_id }];
}
}
vec![]
}
@@ -676,13 +674,12 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
}
Action::LoadPrev => {
if let Screen::Main(m) = &mut app.screen {
if m.diary.offset > 0 {
if let Screen::Main(m) = &mut app.screen
&& m.diary.offset > 0 {
let prev = m.diary.offset.saturating_sub(20);
m.diary.offset = prev;
return vec![Command::LoadDiary { offset: prev }];
}
}
vec![]
}
@@ -732,20 +729,18 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
}
Action::DeleteInit => {
if let Screen::Main(m) = &mut app.screen {
if let Some(entry) = m.diary.entries.get(m.diary.selected) {
if let Screen::Main(m) = &mut app.screen
&& let Some(entry) = m.diary.entries.get(m.diary.selected) {
m.diary.delete_pending = Some(entry.review.id);
}
}
vec![]
}
Action::DeleteConfirm => {
if let Screen::Main(m) = &mut app.screen {
if let Some(review_id) = m.diary.delete_pending.take() {
if let Screen::Main(m) = &mut app.screen
&& let Some(review_id) = m.diary.delete_pending.take() {
return vec![Command::DeleteReview(review_id)];
}
}
vec![]
}
@@ -782,26 +777,24 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
// ── Add Review ────────────────────────────────────────────────────────
Action::RatingUp => {
if let Screen::Main(m) = &mut app.screen {
if m.add_review.rating < 5 {
if let Screen::Main(m) = &mut app.screen
&& m.add_review.rating < 5 {
m.add_review.rating += 1;
}
}
vec![]
}
Action::RatingDown => {
if let Screen::Main(m) = &mut app.screen {
if m.add_review.rating > 0 {
if let Screen::Main(m) = &mut app.screen
&& m.add_review.rating > 0 {
m.add_review.rating -= 1;
}
}
vec![]
}
Action::ReviewSubmit => {
if let Screen::Main(m) = &app.screen {
if m.tab == Tab::AddReview {
if let Screen::Main(m) = &app.screen
&& m.tab == Tab::AddReview {
let f = &m.add_review;
let has_ext = !f.external_id.is_empty();
let has_title = !f.title.is_empty();
@@ -851,7 +844,6 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
app.loading = true;
return vec![Command::CreateReview(req)];
}
}
vec![]
}
@@ -878,8 +870,8 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
// ── Bulk Import ───────────────────────────────────────────────────────
Action::BulkParseFile => {
if let Screen::Main(m) = &mut app.screen {
if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath {
if let Screen::Main(m) = &mut app.screen
&& m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath {
let path = m.bulk_import.file_path.trim().to_string();
match std::fs::read_to_string(&path) {
Ok(content) => {
@@ -894,13 +886,12 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
}
}
}
}
vec![]
}
Action::BulkImportAll => {
if let Screen::Main(m) = &mut app.screen {
if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview {
if let Screen::Main(m) = &mut app.screen
&& m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview {
let valid: Vec<LogReviewRequest> = m
.bulk_import
.parsed
@@ -919,7 +910,6 @@ pub fn update(app: &mut App, action: Action) -> Vec<Command> {
m.bulk_import.stage = BulkImportStage::Importing { done: 0 };
return vec![Command::ImportNext(0)];
}
}
vec![]
}

View File

@@ -41,8 +41,8 @@ async fn run() -> anyhow::Result<()> {
let mut terminal = ratatui::init();
// If we start directly in Main (saved token), trigger an initial diary load
if matches!(app.screen, Screen::Main(_)) {
if let Some(token) = &saved_token {
if matches!(app.screen, Screen::Main(_))
&& let Some(token) = &saved_token {
let c = client.clone();
let t = token.clone();
let tx2 = tx.clone();
@@ -57,15 +57,14 @@ async fn run() -> anyhow::Result<()> {
let _ = tx2.send(action).await;
});
}
}
let result = async {
loop {
terminal.draw(|f| tui::ui::render(f, &app))?;
// Poll keyboard — non-blocking with short timeout
if event::poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if event::poll(Duration::from_millis(50))?
&& let Event::Key(key) = event::read()? {
if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
continue;
}
@@ -79,7 +78,6 @@ async fn run() -> anyhow::Result<()> {
}
}
}
}
// Drain async results
while let Ok(action) = rx.try_recv() {

View File

@@ -43,6 +43,11 @@ fn diary_entry() -> DiaryEntryDto {
release_year: 1999,
director: None,
poster_path: None,
genres: vec![],
runtime_minutes: None,
original_language: None,
overview: None,
collection_name: None,
},
review: ReviewDto {
id: Uuid::new_v4(),

View File

@@ -4,11 +4,11 @@ version = "0.1.0"
edition = "2024"
[features]
default = ["sqlite"]
default = ["sqlite", "sqlite-federation"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
federation = []
federation = ["application/federation"]
sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"]
postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"]

View File

@@ -4,7 +4,7 @@ use anyhow::Context;
use domain::ports::{
DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository,
ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository,
};
pub enum DbPool {
@@ -23,6 +23,7 @@ pub struct Repos {
pub import_session: Arc<dyn ImportSessionRepository>,
pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
pub watchlist: Arc<dyn WatchlistRepository>,
pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub person_command: Arc<dyn PersonCommand>,
@@ -35,26 +36,26 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos
match backend {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp) =
let (pool, m, r, d, s, u, is, ip, mp, wl) =
postgres::wire(database_url).await.context("PostgreSQL connection failed")?;
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone());
let (person_command, person_query) = postgres::create_person_adapter(pool.clone());
let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
image_ref_command, image_ref_query,
person_command, person_query, search_command, search_port },
DbPool::Postgres(pool)))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp) =
let (pool, m, r, d, s, u, is, ip, mp, wl) =
sqlite::wire(database_url).await.context("SQLite connection failed")?;
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone());
let (person_command, person_query) = sqlite::create_person_adapter(pool.clone());
let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone());
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
import_session: is, import_profile: ip, movie_profile: mp,
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
image_ref_command, image_ref_query,
person_command, person_query, search_command, search_port },
DbPool::Sqlite(pool)))

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
#[cfg(feature = "nats")]
use anyhow::Context;
use domain::ports::{EventConsumer, EventPublisher};

View File

@@ -48,6 +48,14 @@ async fn main() -> anyhow::Result<()> {
app_config.base_url.clone(),
app_config.allow_registration,
);
// Wire federation repos early to get remote_watchlist_repo for AppContext.
#[cfg(feature = "federation")]
let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) = match &db_pool {
#[cfg(feature = "sqlite-federation")]
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")]
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
};
let ctx = AppContext {
movie_repository: repos.movie,
@@ -66,6 +74,9 @@ async fn main() -> anyhow::Result<()> {
import_session_repository: repos.import_session,
import_profile_repository: repos.import_profile,
movie_profile_repository: repos.movie_profile,
watchlist_repository: repos.watchlist,
#[cfg(feature = "federation")]
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
person_command: Arc::clone(&person_command),
person_query: Arc::clone(&person_query),
search_port: Arc::clone(&search_port),
@@ -155,16 +166,11 @@ async fn main() -> anyhow::Result<()> {
#[cfg(feature = "federation")]
{
let (federation_repo, _social_query, review_store) = match &db_pool {
#[cfg(feature = "sqlite-federation")]
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")]
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
};
let ap = activitypub::wire(
federation_repo,
review_store,
fed_federation_repo,
fed_review_store,
fed_remote_watchlist_repo,
Arc::clone(&ctx.watchlist_repository),
fed_user_repo,
fed_movie_repo,
fed_review_repo,