feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,3 +14,5 @@
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
|
||||
imgs/
|
||||
@@ -200,7 +200,7 @@ pub struct UndoActivity {
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: UndoType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: FollowActivity,
|
||||
pub(crate) object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -223,19 +223,51 @@ impl Activity for UndoActivity {
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
|
||||
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
|
||||
data.federation_repo
|
||||
.remove_follower(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
|
||||
let obj_type = self.object.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match obj_type {
|
||||
"Follow" => {
|
||||
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str()) {
|
||||
if let Ok(url) = Url::parse(obj_url) {
|
||||
if let Some(user_id) = crate::urls::extract_user_id_from_url(&url) {
|
||||
data.federation_repo
|
||||
.remove_follower(user_id, self.actor.inner().as_str())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
data.object_handler
|
||||
.on_actor_removed(self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||
}
|
||||
"Add" => {
|
||||
let ap_id_str = self.object
|
||||
.get("object")
|
||||
.and_then(|o| o.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
|
||||
|
||||
if let Some(ap_id_str) = ap_id_str {
|
||||
if let Ok(ap_id) = Url::parse(ap_id_str) {
|
||||
data.object_handler
|
||||
.on_delete(&ap_id, self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
|
||||
}
|
||||
}
|
||||
data.object_handler
|
||||
.on_actor_removed(self.actor.inner())
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %self.actor.inner(), "unfollowed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -430,6 +462,56 @@ impl Activity for AnnounceActivity {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Add")]
|
||||
pub struct AddType;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddActivity {
|
||||
pub(crate) id: Url,
|
||||
#[serde(rename = "type", default)]
|
||||
pub(crate) kind: AddType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Activity for AddActivity {
|
||||
type DataType = FederationData;
|
||||
type Error = Error;
|
||||
|
||||
fn id(&self) -> &Url {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn actor(&self) -> &Url {
|
||||
self.actor.inner()
|
||||
}
|
||||
|
||||
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
|
||||
let domain = self.actor().host_str().unwrap_or("");
|
||||
if data.federation_repo.is_domain_blocked(domain).await? {
|
||||
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = self.id.clone();
|
||||
let actor_url = self.actor.inner().clone();
|
||||
data.object_handler
|
||||
.on_create(&ap_id, &actor_url, self.object)
|
||||
.await
|
||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
||||
tracing::info!(actor = %actor_url, "received Add activity");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Block ---
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
@@ -503,6 +585,8 @@ pub enum InboxActivities {
|
||||
Update(UpdateActivity),
|
||||
#[serde(rename = "Announce")]
|
||||
Announce(AnnounceActivity),
|
||||
#[serde(rename = "Add")]
|
||||
Add(AddActivity),
|
||||
#[serde(rename = "Block")]
|
||||
Block(BlockActivity),
|
||||
}
|
||||
|
||||
@@ -34,11 +34,10 @@ fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
|
||||
.shared_inbox_url
|
||||
.as_deref()
|
||||
.unwrap_or(&f.actor.inbox_url);
|
||||
if seen.insert(inbox_str.to_string()) {
|
||||
if let Ok(url) = Url::parse(inbox_str) {
|
||||
if seen.insert(inbox_str.to_string())
|
||||
&& let Ok(url) = Url::parse(inbox_str) {
|
||||
inboxes.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
inboxes
|
||||
}
|
||||
@@ -228,7 +227,7 @@ impl ActivityPubService {
|
||||
id: undo_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: follow,
|
||||
object: serde_json::to_value(&follow).map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
};
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
@@ -565,6 +564,131 @@ impl ActivityPubService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast an Add(WatchlistObject) activity to all accepted followers.
|
||||
pub async fn broadcast_add_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
ap_id: Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let add = crate::activities::AddActivity {
|
||||
id: ap_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
};
|
||||
let add_with_ctx = WithContext::new_default(add);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&add_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Add deliveries failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast an Undo(Add) activity to all accepted followers.
|
||||
pub async fn broadcast_undo_add_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
watchlist_entry_ap_id: Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data
|
||||
.federation_repo
|
||||
.get_blocked_actors(local_user_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data
|
||||
.federation_repo
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let undo_id = crate::urls::activity_url(&self.base_url)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let undo = crate::activities::UndoActivity {
|
||||
id: undo_id,
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: serde_json::json!({
|
||||
"type": "Add",
|
||||
"id": watchlist_entry_ap_id.as_str(),
|
||||
"object": { "id": watchlist_entry_ap_id.as_str() }
|
||||
}),
|
||||
};
|
||||
let undo_with_ctx = WithContext::new_default(undo);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends =
|
||||
SendActivityTask::prepare(&undo_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Undo(Add) deliveries failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Broadcast an Update(Note) activity to all accepted followers for an edited review.
|
||||
pub async fn broadcast_update_to_followers(
|
||||
&self,
|
||||
@@ -812,7 +936,7 @@ impl ActivityPubService {
|
||||
data.federation_repo
|
||||
.update_following_status(
|
||||
local_user_id,
|
||||
&target_actor_url.to_string(),
|
||||
target_actor_url.as_ref(),
|
||||
FollowingStatus::Accepted,
|
||||
)
|
||||
.await?;
|
||||
|
||||
77
crates/adapters/activitypub/src/composite_handler.rs
Normal file
77
crates/adapters/activitypub/src/composite_handler.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
|
||||
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
|
||||
|
||||
pub struct CompositeObjectHandler {
|
||||
pub review: Arc<ReviewObjectHandler>,
|
||||
pub watchlist: Arc<WatchlistObjectHandler>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for CompositeObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
||||
self.review.get_local_objects_for_user(user_id).await
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
self.review.get_local_objects_page(user_id, before, limit).await
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
if object.get("rating").is_some() {
|
||||
self.review.on_create(ap_id, actor_url, object).await
|
||||
} else if object.get("movieTitle").is_some() {
|
||||
self.watchlist.on_create(ap_id, actor_url, object).await
|
||||
} else {
|
||||
tracing::debug!(ap_id = %ap_id, "ignoring Create for unknown object type");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
if object.get("rating").is_some() {
|
||||
self.review.on_update(ap_id, actor_url, object).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
|
||||
self.review.on_delete(ap_id, actor_url).await?;
|
||||
self.watchlist.on_delete(ap_id, actor_url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
||||
self.review.on_actor_removed(actor_url).await?;
|
||||
self.watchlist.on_actor_removed(actor_url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.review.count_local_posts().await
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use domain::ports::EventHandler;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{MovieRepository, ReviewRepository},
|
||||
ports::{MovieRepository, ReviewRepository, WatchlistRepository},
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -17,6 +17,7 @@ pub struct ActivityPubEventHandler {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
review_repository: Arc<dyn ReviewRepository>,
|
||||
watchlist_repository: Arc<dyn WatchlistRepository>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
@@ -25,12 +26,14 @@ impl ActivityPubEventHandler {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
review_repository: Arc<dyn ReviewRepository>,
|
||||
watchlist_repository: Arc<dyn WatchlistRepository>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
movie_repository,
|
||||
review_repository,
|
||||
watchlist_repository,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +60,21 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
.broadcast_actor_update(user_id.value())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::WatchlistEntryAdded {
|
||||
user_id,
|
||||
movie_id,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
added_at,
|
||||
} => self
|
||||
.on_watchlist_added(user_id, movie_id, movie_title, *release_year, external_metadata_id, added_at)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::WatchlistEntryRemoved { user_id, movie_id } => self
|
||||
.on_watchlist_removed(user_id, movie_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -162,4 +180,58 @@ impl ActivityPubEventHandler {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_watchlist_added(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &domain::value_objects::MovieId,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: &Option<String>,
|
||||
added_at: &chrono::NaiveDateTime,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::urls::watchlist_entry_url;
|
||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
|
||||
let poster_url = self
|
||||
.movie_repository
|
||||
.get_movie_by_id(movie_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|m| m.poster_path().map(|p| format!("{}/images/{}", self.base_url, p.value())));
|
||||
|
||||
let added_at_utc =
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
||||
let obj = crate::objects::watchlist_to_ap_object(
|
||||
ap_id.clone(),
|
||||
actor,
|
||||
movie_title.to_string(),
|
||||
release_year,
|
||||
external_metadata_id.clone(),
|
||||
poster_url,
|
||||
added_at_utc,
|
||||
&self.base_url,
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_add_to_followers(user_id.value(), ap_id, json)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_watchlist_removed(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &domain::value_objects::MovieId,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::urls::watchlist_entry_url;
|
||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
||||
self.ap_service
|
||||
.broadcast_undo_add_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod composite_handler;
|
||||
pub mod event_handler;
|
||||
pub mod objects;
|
||||
pub mod port;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod watchlist_handler;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
|
||||
@@ -25,25 +27,36 @@ pub struct ActivityPubWire {
|
||||
}
|
||||
|
||||
pub async fn wire(
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
watchlist_repo: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
base_url: String,
|
||||
allow_registration: bool,
|
||||
) -> anyhow::Result<ActivityPubWire> {
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
movie_repository: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repository: std::sync::Arc::clone(&diary_repo),
|
||||
review_store,
|
||||
base_url: base_url.clone(),
|
||||
});
|
||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||
remote_watchlist_repo,
|
||||
});
|
||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||
review: review_handler,
|
||||
watchlist: watchlist_handler,
|
||||
});
|
||||
|
||||
let concrete = std::sync::Arc::new(
|
||||
ActivityPubService::new(
|
||||
federation_repo,
|
||||
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
|
||||
std::sync::Arc::new(ReviewObjectHandler {
|
||||
movie_repository: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repository: diary_repo,
|
||||
review_store,
|
||||
base_url: base_url.clone(),
|
||||
}),
|
||||
composite,
|
||||
base_url.clone(),
|
||||
allow_registration,
|
||||
"movies-diary".to_string(),
|
||||
@@ -57,6 +70,7 @@ pub async fn wire(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
movie_repo,
|
||||
review_repo,
|
||||
watchlist_repo,
|
||||
base_url,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
|
||||
@@ -97,6 +97,72 @@ pub fn review_to_ap_object(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchlistObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
pub(crate) published: chrono::DateTime<chrono::Utc>,
|
||||
pub(crate) movie_title: String,
|
||||
#[serde(default)]
|
||||
pub(crate) release_year: u16,
|
||||
#[serde(default)]
|
||||
pub(crate) external_metadata_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) poster_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) tag: Vec<ApHashtag>,
|
||||
}
|
||||
|
||||
pub fn watchlist_to_ap_object(
|
||||
ap_id: Url,
|
||||
actor_url: Url,
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<String>,
|
||||
poster_url: Option<String>,
|
||||
added_at: chrono::DateTime<chrono::Utc>,
|
||||
base_url: &str,
|
||||
) -> WatchlistObject {
|
||||
let year_str = if release_year > 0 {
|
||||
format!(" ({})", release_year)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let content = format!("📋 {}{} — want to watch", movie_title, year_str);
|
||||
let normalized = normalize_hashtag(&movie_title);
|
||||
let tag = vec![
|
||||
ApHashtag {
|
||||
kind: "Hashtag".to_string(),
|
||||
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
|
||||
.expect("valid base_url"),
|
||||
name: "#MoviesDiary".to_string(),
|
||||
},
|
||||
ApHashtag {
|
||||
kind: "Hashtag".to_string(),
|
||||
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
|
||||
.expect("valid base_url"),
|
||||
name: format!("#{}", normalized),
|
||||
},
|
||||
];
|
||||
|
||||
WatchlistObject {
|
||||
kind: NoteType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url,
|
||||
content,
|
||||
published: added_at,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
poster_url,
|
||||
tag,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/objects.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -100,11 +100,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
let published =
|
||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||
|
||||
if let Some(cutoff) = before {
|
||||
if published >= cutoff {
|
||||
if let Some(cutoff) = before
|
||||
&& published >= cutoff {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor_url = actor_url(&self.base_url, user_id);
|
||||
|
||||
@@ -12,3 +12,9 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
||||
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
|
||||
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/watchlist/{}", base_url, user_id, movie_id))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
82
crates/adapters/activitypub/src/watchlist_handler.rs
Normal file
82
crates/adapters/activitypub/src/watchlist_handler.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::WatchlistObject;
|
||||
|
||||
pub struct WatchlistObjectHandler {
|
||||
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for WatchlistObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
_user_id: uuid::Uuid,
|
||||
_before: Option<chrono::DateTime<Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, chrono::DateTime<Utc>)>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let obj: WatchlistObject = serde_json::from_value(object)?;
|
||||
let added_at = obj.published;
|
||||
let entry = RemoteWatchlistEntry {
|
||||
ap_id: ap_id.as_str().to_string(),
|
||||
actor_url: actor_url.as_str().to_string(),
|
||||
movie_title: obj.movie_title,
|
||||
release_year: obj.release_year,
|
||||
external_metadata_id: obj.external_metadata_id,
|
||||
poster_url: obj.poster_url,
|
||||
added_at,
|
||||
};
|
||||
self.remote_watchlist_repo.save(entry).await?;
|
||||
tracing::info!(ap_id = %ap_id, "saved remote watchlist entry");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
_ap_id: &Url,
|
||||
_actor_url: &Url,
|
||||
_object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
|
||||
self.remote_watchlist_repo
|
||||
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
|
||||
.await?;
|
||||
tracing::info!(ap_id = %ap_id, "removed remote watchlist entry");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
|
||||
self.remote_watchlist_repo
|
||||
.remove_all_by_actor(actor_url.as_str())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,10 @@ impl From<&DomainEvent> for EventPayload {
|
||||
}
|
||||
}
|
||||
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
|
||||
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => {
|
||||
// federation-only events; not serialized via EventPayload
|
||||
unreachable!("watchlist events are handled by the AP event handler directly")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +158,7 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
EventPayload::MovieDeleted { movie_id, poster_path } => {
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?);
|
||||
let poster_path = poster_path
|
||||
.map(|p| PosterPath::new(p))
|
||||
.map(PosterPath::new)
|
||||
.transpose()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
|
||||
|
||||
@@ -43,7 +43,7 @@ impl EventHandler for ImageConversionHandler {
|
||||
let converted = tokio::task::spawn_blocking(move || convert(bytes, format))
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.map_err(|e| DomainError::InfrastructureError(e))?;
|
||||
.map_err(DomainError::InfrastructureError)?;
|
||||
|
||||
let ext = format.extension();
|
||||
let new_key = format!("{key}{ext}");
|
||||
|
||||
@@ -7,14 +7,9 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
ports::{EventHandler, ImageStorage},
|
||||
};
|
||||
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
|
||||
use object_store::{ObjectStore, path::Path};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn detect_mime(bytes: &[u8]) -> &'static str {
|
||||
infer::get(bytes)
|
||||
.map(|t| t.mime_type())
|
||||
.unwrap_or("application/octet-stream")
|
||||
}
|
||||
|
||||
pub struct ImageStorageAdapter {
|
||||
store: Arc<dyn ObjectStore>,
|
||||
@@ -34,12 +29,8 @@ impl ImageStorageAdapter {
|
||||
impl ImageStorage for ImageStorageAdapter {
|
||||
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError> {
|
||||
let path = Path::from(key);
|
||||
let mime = detect_mime(image_bytes);
|
||||
let mut attributes = Attributes::new();
|
||||
attributes.insert(Attribute::ContentType, mime.into());
|
||||
let opts = PutOptions { attributes, ..Default::default() };
|
||||
self.store
|
||||
.put_opts(&path, image_bytes.to_vec().into(), opts)
|
||||
.put(&path, image_bytes.to_vec().into())
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(key.to_string())
|
||||
|
||||
@@ -10,6 +10,9 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::UserUpdated { .. } => "user.updated",
|
||||
DomainEvent::MovieEnrichmentRequested { .. } => "movie.enrichment.requested",
|
||||
DomainEvent::ImageStored { .. } => "image.stored",
|
||||
DomainEvent::WatchlistEntryAdded { .. } | DomainEvent::WatchlistEntryRemoved { .. } => {
|
||||
unreachable!("watchlist events are not published to NATS")
|
||||
}
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
@@ -69,6 +69,19 @@ impl EventHandler for PosterSyncHandler {
|
||||
DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => {
|
||||
(movie_id.value(), external_metadata_id.value().to_owned())
|
||||
}
|
||||
DomainEvent::MovieEnrichmentRequested { movie_id, external_metadata_id } => {
|
||||
// Only sync poster if the movie doesn't have one yet
|
||||
let already_has_poster = self
|
||||
.movie_repository
|
||||
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
|
||||
.await?
|
||||
.map(|m| m.poster_path().is_some())
|
||||
.unwrap_or(false);
|
||||
if already_has_poster {
|
||||
return Ok(());
|
||||
}
|
||||
(movie_id.value(), external_metadata_id.clone())
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource};
|
||||
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
|
||||
use domain::ports::RemoteWatchlistRepository;
|
||||
|
||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
@@ -609,13 +610,104 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id)
|
||||
.bind(&entry.actor_url)
|
||||
.bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i32)
|
||||
.bind(&entry.external_metadata_id)
|
||||
.bind(&entry.poster_url)
|
||||
.bind(entry.added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|row| {
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at: row.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at")
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
|
||||
let target = actors.into_iter().find(|url| {
|
||||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid
|
||||
});
|
||||
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire(pool: sqlx::PgPool) -> (
|
||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
) {
|
||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
||||
(
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
fed as _,
|
||||
|
||||
9
crates/adapters/postgres/migrations/0016_watchlist.sql
Normal file
9
crates/adapters/postgres/migrations/0016_watchlist.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS watchlist_entries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE(user_id, movie_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries (
|
||||
ap_id TEXT PRIMARY KEY NOT NULL,
|
||||
actor_url TEXT NOT NULL,
|
||||
movie_title TEXT NOT NULL,
|
||||
release_year INTEGER NOT NULL,
|
||||
external_metadata_id TEXT,
|
||||
poster_url TEXT,
|
||||
added_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor
|
||||
ON ap_remote_watchlist_entries(actor_url);
|
||||
@@ -19,10 +19,11 @@ mod models;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod users;
|
||||
mod watchlist;
|
||||
|
||||
use models::{
|
||||
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
|
||||
UserTotalsRow, datetime_to_str,
|
||||
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow,
|
||||
MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str,
|
||||
};
|
||||
|
||||
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
|
||||
@@ -31,6 +32,7 @@ pub use import_session::PostgresImportSessionRepository;
|
||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||
pub use profile::PostgresMovieProfileRepository;
|
||||
pub use users::PostgresUserRepository;
|
||||
pub use watchlist::PostgresWatchlistRepository;
|
||||
|
||||
fn format_year_month(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
@@ -300,7 +302,7 @@ impl MovieRepository for PostgresRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -314,7 +316,7 @@ impl MovieRepository for PostgresRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -335,7 +337,7 @@ impl MovieRepository for PostgresRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -383,21 +385,34 @@ impl MovieRepository for PostgresRepository {
|
||||
async fn list_movies(
|
||||
&self,
|
||||
page: &domain::models::collections::PageParams,
|
||||
search: Option<&str>,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
|
||||
filter: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
|
||||
use sqlx::Row;
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let genre = filter.genre.as_deref();
|
||||
let language = filter.language.as_deref();
|
||||
|
||||
let rows: Vec<models::MovieRow> = sqlx::query_as(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
|
||||
FROM movies \
|
||||
WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \
|
||||
ORDER BY title ASC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
|
||||
"SELECT \
|
||||
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
|
||||
array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
LEFT JOIN movie_genres g ON g.movie_id = m.id \
|
||||
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
|
||||
AND ($2::text IS NULL OR p.original_language = $2) \
|
||||
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \
|
||||
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
|
||||
ORDER BY m.title ASC \
|
||||
LIMIT $4 OFFSET $5",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -405,17 +420,25 @@ impl MovieRepository for PostgresRepository {
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)",
|
||||
"SELECT COUNT(DISTINCT m.id) \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
|
||||
AND ($2::text IS NULL OR p.original_language = $2) \
|
||||
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows.into_iter()
|
||||
.map(|r| r.to_domain())
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(domain::models::collections::Paginated {
|
||||
@@ -480,7 +503,7 @@ impl ReviewRepository for PostgresRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::to_domain)
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -540,7 +563,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(DiaryRow::to_domain)
|
||||
.map(DiaryRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -681,7 +704,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::to_domain)
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -704,7 +727,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
@@ -718,7 +741,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::to_domain)
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ReviewHistory::new(movie, viewings))
|
||||
@@ -742,7 +765,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
rows.into_iter().map(DiaryRow::to_domain).collect()
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||
@@ -763,7 +786,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map(MovieStatsRow::to_domain)
|
||||
.map(MovieStatsRow::into_domain)
|
||||
}
|
||||
|
||||
async fn get_movie_social_feed(
|
||||
@@ -808,7 +831,7 @@ impl DiaryRepository for PostgresRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::to_domain)
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -918,6 +941,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
|
||||
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
)> {
|
||||
use anyhow::Context;
|
||||
|
||||
@@ -934,6 +958,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()));
|
||||
let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()));
|
||||
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone()));
|
||||
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone()));
|
||||
|
||||
Ok((
|
||||
pool.clone(),
|
||||
@@ -945,5 +970,6 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
import_session_repo as _,
|
||||
import_profile_repo as _,
|
||||
movie_profile_repo as _,
|
||||
watchlist_repo as _,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary},
|
||||
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
@@ -20,7 +20,7 @@ pub(crate) struct MovieRow {
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
pub fn to_domain(self) -> Result<Movie, DomainError> {
|
||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
@@ -40,6 +40,43 @@ impl MovieRow {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieSummaryRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub runtime_minutes: Option<i64>,
|
||||
pub original_language: Option<String>,
|
||||
pub overview: Option<String>,
|
||||
pub collection_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieSummaryRow {
|
||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(MovieSummary {
|
||||
movie,
|
||||
genres: self.genres.unwrap_or_default(),
|
||||
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
|
||||
original_language: self.original_language,
|
||||
overview: self.overview,
|
||||
collection_name: self.collection_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ReviewRow {
|
||||
pub id: String,
|
||||
@@ -53,7 +90,7 @@ pub(crate) struct ReviewRow {
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn to_domain(self) -> Result<Review, DomainError> {
|
||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
@@ -90,7 +127,7 @@ pub(crate) struct DiaryRow {
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
@@ -99,7 +136,7 @@ impl DiaryRow {
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
@@ -110,7 +147,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
@@ -135,7 +172,7 @@ pub(crate) struct FeedRow {
|
||||
}
|
||||
|
||||
impl FeedRow {
|
||||
pub fn to_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
let diary = DiaryRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
@@ -152,7 +189,7 @@ impl FeedRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
}
|
||||
}
|
||||
@@ -170,7 +207,7 @@ pub(crate) struct MovieStatsRow {
|
||||
}
|
||||
|
||||
impl MovieStatsRow {
|
||||
pub fn to_domain(self) -> domain::models::MovieStats {
|
||||
pub fn into_domain(self) -> domain::models::MovieStats {
|
||||
domain::models::MovieStats {
|
||||
total_count: self.total_count as u64,
|
||||
avg_rating: self.avg_rating,
|
||||
@@ -195,7 +232,7 @@ pub(crate) struct UserSummaryRow {
|
||||
}
|
||||
|
||||
impl UserSummaryRow {
|
||||
pub fn to_domain(self) -> Result<UserSummary, DomainError> {
|
||||
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&self.id)?),
|
||||
Email::new(self.email)?,
|
||||
|
||||
@@ -231,7 +231,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(UserSummaryRow::to_domain)
|
||||
.map(UserSummaryRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
169
crates/adapters/postgres/src/watchlist.rs
Normal file
169
crates/adapters/postgres/src/watchlist.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId, WatchlistEntryId},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{parse_uuid, parse_datetime, MovieRow};
|
||||
|
||||
pub struct PostgresWatchlistRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresWatchlistRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
let id = entry.id.value().to_string();
|
||||
let user_id = entry.user_id.value().to_string();
|
||||
let movie_id = entry.movie_id.value().to_string();
|
||||
let added_at = entry.added_at;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO watchlist_entries (id, user_id, movie_id, added_at) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (user_id, movie_id) DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
"Watchlist entry for movie {} not found",
|
||||
mid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT w.id, w.user_id, w.movie_id, \
|
||||
to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, \
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \
|
||||
m.director, m.poster_path \
|
||||
FROM watchlist_entries w \
|
||||
JOIN movies m ON m.id = w.movie_id \
|
||||
WHERE w.user_id = $1 \
|
||||
ORDER BY w.added_at DESC \
|
||||
LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&row.try_get::<String, _>("id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&row.try_get::<String, _>("user_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&row.try_get::<String, _>("movie_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?),
|
||||
added_at: parse_datetime(&row.try_get::<String, _>("added_at").map_err(|e| DomainError::InfrastructureError(e.to_string()))?)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
})
|
||||
.collect::<Result<Vec<_>, DomainError>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ use activitypub::RemoteReviewRepository;
|
||||
use activitypub_base::{
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
use domain::models::{Review, ReviewSource};
|
||||
use domain::models::{Review, ReviewSource, RemoteWatchlistEntry};
|
||||
use domain::ports::RemoteWatchlistRepository;
|
||||
|
||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
@@ -672,13 +673,107 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for SqliteFederationRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id)
|
||||
.bind(&entry.actor_url)
|
||||
.bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i64)
|
||||
.bind(&entry.external_metadata_id)
|
||||
.bind(&entry.poster_url)
|
||||
.bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(|row| {
|
||||
let added_at_str: String = row.try_get("added_at").unwrap_or_default();
|
||||
let added_at = chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc))
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at,
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(&self, uuid: uuid::Uuid) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let actors: Vec<String> = sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
|
||||
let target = actors.into_iter().find(|url| {
|
||||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid
|
||||
});
|
||||
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire(pool: sqlx::SqlitePool) -> (
|
||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
) {
|
||||
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
||||
(
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
fed as _,
|
||||
|
||||
@@ -160,7 +160,7 @@ impl SqliteSearchAdapter {
|
||||
}
|
||||
|
||||
let total: u64 = if let Some(text) = &query.text {
|
||||
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(DISTINCT m.id)
|
||||
FROM movies_fts fts
|
||||
@@ -198,7 +198,7 @@ impl SqliteSearchAdapter {
|
||||
};
|
||||
|
||||
let rows: Vec<Row> = if let Some(text) = &query.text {
|
||||
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||
GROUP_CONCAT(DISTINCT mg.name) AS genres
|
||||
@@ -273,7 +273,7 @@ impl SqliteSearchAdapter {
|
||||
|
||||
let limit = query.page.limit as i64;
|
||||
let offset = query.page.offset as i64;
|
||||
let fts_query = format!("{}*", text.replace('"', "").replace('*', ""));
|
||||
let fts_query = format!("{}*", text.replace(['"', '*'], ""));
|
||||
|
||||
let total: u64 = {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
|
||||
9
crates/adapters/sqlite/migrations/0016_watchlist.sql
Normal file
9
crates/adapters/sqlite/migrations/0016_watchlist.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS watchlist_entries (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE,
|
||||
added_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, movie_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC);
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries (
|
||||
ap_id TEXT PRIMARY KEY NOT NULL,
|
||||
actor_url TEXT NOT NULL,
|
||||
movie_title TEXT NOT NULL,
|
||||
release_year INTEGER NOT NULL,
|
||||
external_metadata_id TEXT,
|
||||
poster_url TEXT,
|
||||
added_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor
|
||||
ON ap_remote_watchlist_entries(actor_url);
|
||||
@@ -20,10 +20,11 @@ mod models;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod users;
|
||||
mod watchlist;
|
||||
|
||||
use models::{
|
||||
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow, ReviewRow,
|
||||
UserTotalsRow, datetime_to_str,
|
||||
DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, MovieStatsRow,
|
||||
MovieSummaryRow, ReviewRow, UserTotalsRow, datetime_to_str,
|
||||
};
|
||||
|
||||
pub use image_ref::{SqliteImageRefAdapter, create_image_ref};
|
||||
@@ -32,6 +33,7 @@ pub use import_session::SqliteImportSessionRepository;
|
||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||
pub use profile::SqliteMovieProfileRepository;
|
||||
pub use users::SqliteUserRepository;
|
||||
pub use watchlist::SqliteWatchlistRepository;
|
||||
|
||||
fn format_year_month(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
@@ -307,7 +309,7 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -322,7 +324,7 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -344,7 +346,7 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(MovieRow::to_domain)
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -391,22 +393,37 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
async fn list_movies(
|
||||
&self,
|
||||
page: &domain::models::collections::PageParams,
|
||||
search: Option<&str>,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
|
||||
filter: &domain::models::MovieFilter,
|
||||
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
|
||||
use sqlx::Row;
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let pattern = filter.search.as_deref().map(|s| format!("%{}%", s.to_lowercase()));
|
||||
let genre = filter.genre.as_deref();
|
||||
let language = filter.language.as_deref();
|
||||
|
||||
let rows: Vec<models::MovieRow> = sqlx::query_as(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
|
||||
FROM movies \
|
||||
WHERE (? IS NULL OR LOWER(title) LIKE ?) \
|
||||
ORDER BY title ASC \
|
||||
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
|
||||
"SELECT \
|
||||
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
|
||||
GROUP_CONCAT(g.name) AS genres \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
LEFT JOIN movie_genres g ON g.movie_id = m.id \
|
||||
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
|
||||
AND (? IS NULL OR p.original_language = ?) \
|
||||
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?))) \
|
||||
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
|
||||
ORDER BY m.title ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.bind(genre)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -414,18 +431,28 @@ impl MovieRepository for SqliteMovieRepository {
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)",
|
||||
"SELECT COUNT(DISTINCT m.id) \
|
||||
FROM movies m \
|
||||
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
|
||||
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
|
||||
AND (? IS NULL OR p.original_language = ?) \
|
||||
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?)))",
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(&pattern)
|
||||
.bind(language)
|
||||
.bind(language)
|
||||
.bind(genre)
|
||||
.bind(genre)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows.into_iter()
|
||||
.map(|r| r.to_domain())
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(domain::models::collections::Paginated {
|
||||
@@ -488,7 +515,7 @@ impl ReviewRepository for SqliteMovieRepository {
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::to_domain)
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -547,7 +574,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(DiaryRow::to_domain)
|
||||
.map(DiaryRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -674,7 +701,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::to_domain)
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -698,7 +725,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
|
||||
let viewings = sqlx::query_as!(
|
||||
ReviewRow,
|
||||
@@ -710,7 +737,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::to_domain)
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ReviewHistory::new(movie, viewings))
|
||||
@@ -732,7 +759,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
rows.into_iter().map(DiaryRow::to_domain).collect()
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||
@@ -753,7 +780,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map(MovieStatsRow::to_domain)
|
||||
.map(MovieStatsRow::into_domain)
|
||||
}
|
||||
|
||||
async fn get_movie_social_feed(
|
||||
@@ -796,7 +823,7 @@ impl DiaryRepository for SqliteMovieRepository {
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(FeedRow::to_domain)
|
||||
.map(FeedRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
@@ -909,6 +936,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
|
||||
std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
)> {
|
||||
use std::str::FromStr;
|
||||
use anyhow::Context;
|
||||
@@ -932,6 +960,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone()));
|
||||
let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone()));
|
||||
let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone()));
|
||||
let watchlist_repo = std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone()));
|
||||
|
||||
Ok((
|
||||
pool.clone(),
|
||||
@@ -943,6 +972,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||
import_session_repo as _,
|
||||
import_profile_repo as _,
|
||||
movie_profile_repo as _,
|
||||
watchlist_repo as _,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary},
|
||||
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, WatchlistEntry, WatchlistWithMovie},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -20,7 +20,7 @@ pub(crate) struct MovieRow {
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
pub fn to_domain(self) -> Result<Movie, DomainError> {
|
||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
@@ -40,6 +40,47 @@ impl MovieRow {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieSummaryRow {
|
||||
pub id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
pub genres: Option<String>,
|
||||
pub runtime_minutes: Option<i64>,
|
||||
pub original_language: Option<String>,
|
||||
pub overview: Option<String>,
|
||||
pub collection_name: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieSummaryRow {
|
||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let genres = self
|
||||
.genres
|
||||
.map(|g| g.split(',').map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(MovieSummary {
|
||||
movie,
|
||||
genres,
|
||||
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
|
||||
original_language: self.original_language,
|
||||
overview: self.overview,
|
||||
collection_name: self.collection_name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ReviewRow {
|
||||
pub id: String,
|
||||
@@ -53,7 +94,7 @@ pub(crate) struct ReviewRow {
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn to_domain(self) -> Result<Review, DomainError> {
|
||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
@@ -91,7 +132,7 @@ pub(crate) struct DiaryRow {
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
@@ -100,7 +141,7 @@ impl DiaryRow {
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
@@ -112,7 +153,7 @@ impl DiaryRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
@@ -131,7 +172,7 @@ pub(crate) struct MovieStatsRow {
|
||||
}
|
||||
|
||||
impl MovieStatsRow {
|
||||
pub fn to_domain(self) -> domain::models::MovieStats {
|
||||
pub fn into_domain(self) -> domain::models::MovieStats {
|
||||
domain::models::MovieStats {
|
||||
total_count: self.total_count as u64,
|
||||
avg_rating: self.avg_rating,
|
||||
@@ -168,7 +209,7 @@ pub(crate) struct FeedRow {
|
||||
}
|
||||
|
||||
impl FeedRow {
|
||||
pub fn to_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
let diary = DiaryRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
@@ -185,7 +226,7 @@ impl FeedRow {
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.to_domain()?;
|
||||
.into_domain()?;
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
}
|
||||
}
|
||||
@@ -199,7 +240,7 @@ pub(crate) struct UserSummaryRow {
|
||||
}
|
||||
|
||||
impl UserSummaryRow {
|
||||
pub fn to_domain(self) -> Result<UserSummary, DomainError> {
|
||||
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&self.id)?),
|
||||
Email::new(self.email)?,
|
||||
@@ -228,6 +269,41 @@ pub(crate) struct MonthlyRatingRow {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct WatchlistRow {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub movie_id: String,
|
||||
pub added_at: String,
|
||||
pub m_id: String,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub title: String,
|
||||
pub release_year: i64,
|
||||
pub director: Option<String>,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
|
||||
added_at: parse_datetime(&self.added_at)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: self.m_id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
|
||||
@@ -202,7 +202,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(UserSummaryRow::to_domain)
|
||||
.map(UserSummaryRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
154
crates/adapters/sqlite/src/watchlist.rs
Normal file
154
crates/adapters/sqlite/src/watchlist.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{WatchlistEntry, WatchlistWithMovie, collections::{PageParams, Paginated}},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::models::{WatchlistRow, datetime_to_str};
|
||||
|
||||
pub struct SqliteWatchlistRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWatchlistRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for SqliteWatchlistRepository {
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
let id = entry.id.value().to_string();
|
||||
let user_id = entry.user_id.value().to_string();
|
||||
let movie_id = entry.movie_id.value().to_string();
|
||||
let added_at = datetime_to_str(&entry.added_at);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&movie_id)
|
||||
.bind(&added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
"Watchlist entry for movie {} not found",
|
||||
mid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit = page.limit as i64;
|
||||
let offset = page.offset as i64;
|
||||
|
||||
let rows: Vec<WatchlistRow> = sqlx::query_as(
|
||||
"SELECT w.id, w.user_id, w.movie_id, w.added_at, \
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \
|
||||
m.director, m.poster_path \
|
||||
FROM watchlist_entries w \
|
||||
JOIN movies m ON m.id = w.movie_id \
|
||||
WHERE w.user_id = ? \
|
||||
ORDER BY w.added_at DESC \
|
||||
LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total_count: total as u64,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let mid = movie_id.value().to_string();
|
||||
let count: i64 = sqlx::query(
|
||||
"SELECT COUNT(*) FROM watchlist_entries WHERE user_id = ? AND movie_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&mid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use application::ports::{
|
||||
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData,
|
||||
};
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
@@ -101,13 +101,28 @@ struct MovieDetailTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
movie: &'a domain::models::Movie,
|
||||
stats: &'a domain::models::MovieStats,
|
||||
profile: Option<&'a domain::models::MovieProfile>,
|
||||
reviews: &'a [domain::models::FeedEntry],
|
||||
on_watchlist: bool,
|
||||
current_offset: u32,
|
||||
has_more: bool,
|
||||
limit: u32,
|
||||
histogram_max: u64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watchlist.html")]
|
||||
struct WatchlistTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
owner_id: uuid::Uuid,
|
||||
display_entries: &'a [application::ports::WatchlistDisplayEntry],
|
||||
current_offset: u32,
|
||||
has_more: bool,
|
||||
limit: u32,
|
||||
is_owner: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> ActivityFeedTemplate<'a> {
|
||||
pub fn filter_qs(&self) -> String {
|
||||
let mut parts = vec![
|
||||
@@ -358,6 +373,7 @@ struct ImportPreviewTemplate<'a> {
|
||||
rows: &'a [ImportPreviewRow],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AskamaHtmlRenderer;
|
||||
|
||||
impl AskamaHtmlRenderer {
|
||||
@@ -374,7 +390,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
) -> Result<String, String> {
|
||||
let has_more = (data.offset + data.limit) < data.total_count as u32;
|
||||
let (total_pages, current_page) = if data.limit > 0 {
|
||||
let tp = ((data.total_count + data.limit as u64 - 1) / data.limit as u64) as u32;
|
||||
let tp = data.total_count.div_ceil(data.limit as u64) as u32;
|
||||
(tp, data.offset / data.limit)
|
||||
} else {
|
||||
(0, 0)
|
||||
@@ -420,16 +436,8 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
|
||||
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
|
||||
let limit = data.limit;
|
||||
let total_pages = if limit > 0 {
|
||||
((data.entries.total_count + limit as u64 - 1) / limit as u64) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let current_page = if limit > 0 {
|
||||
data.current_offset / limit
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let total_pages = data.entries.total_count.div_ceil(limit.max(1) as u64) as u32;
|
||||
let current_page = data.current_offset.checked_div(limit).unwrap_or(0);
|
||||
ActivityFeedTemplate {
|
||||
entries: &data.entries.items,
|
||||
current_offset: data.current_offset,
|
||||
@@ -496,7 +504,7 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
let heatmap = data
|
||||
.history
|
||||
.as_deref()
|
||||
.map(|h| build_heatmap(h))
|
||||
.map(build_heatmap)
|
||||
.unwrap_or_default();
|
||||
let profile_display_name = data
|
||||
.profile_user_email
|
||||
@@ -521,18 +529,10 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.entries
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
if e.limit > 0 {
|
||||
((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
e.total_count.div_ceil(e.limit.max(1) as u64) as u32
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let current_page = if data.limit > 0 {
|
||||
data.current_offset / data.limit
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
|
||||
let avg_rating_display = data
|
||||
.stats
|
||||
.avg_rating
|
||||
@@ -594,7 +594,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
ctx: &data.ctx,
|
||||
movie: &data.movie,
|
||||
stats: &data.stats,
|
||||
profile: data.profile.as_ref(),
|
||||
reviews: &data.reviews.items,
|
||||
on_watchlist: data.on_watchlist,
|
||||
current_offset: data.current_offset,
|
||||
has_more: data.has_more,
|
||||
limit: data.limit,
|
||||
@@ -604,6 +606,21 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_watchlist_page(&self, data: WatchlistPageData) -> Result<String, String> {
|
||||
WatchlistTemplate {
|
||||
ctx: &data.ctx,
|
||||
owner_id: data.owner_id,
|
||||
display_entries: &data.display_entries,
|
||||
current_offset: data.current_offset,
|
||||
has_more: data.has_more,
|
||||
limit: data.limit,
|
||||
is_owner: data.is_owner,
|
||||
error: data.error,
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String> {
|
||||
FollowingTemplate {
|
||||
ctx: data.ctx,
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/webp" href="/static/logo.webp" />
|
||||
<link rel="apple-touch-icon" href="/static/logo.webp" />
|
||||
<link rel="manifest" href="/static/manifest.json" />
|
||||
<meta name="theme-color" content="#e5c034" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
|
||||
{# ── Hero ── #}
|
||||
<article class="entry" style="margin-bottom:1.5rem">
|
||||
{% if let Some(poster) = movie.poster_path() %}
|
||||
<div class="poster"><img src="/posters/{{ poster.value() }}" alt=""></div>
|
||||
@@ -11,15 +12,46 @@
|
||||
{{ movie.title().value() }}
|
||||
<span class="year">({{ movie.release_year().value() }})</span>
|
||||
</div>
|
||||
{% if let Some(dir) = movie.director() %}
|
||||
<div class="director">{{ dir }}</div>
|
||||
<div class="movie-meta">
|
||||
{% if let Some(dir) = movie.director() %}{{ dir }}{% endif %}
|
||||
{% if let Some(p) = profile %}
|
||||
{% if let Some(runtime) = p.runtime_minutes %}· {{ runtime }} min{% endif %}
|
||||
{% if let Some(lang) = &p.original_language %}· {{ lang|upper }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(p) = profile %}
|
||||
{% if !p.genres.is_empty() %}
|
||||
<div class="genre-pills">
|
||||
{% for g in &p.genres %}<span class="genre-pill">{{ g.name }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(tagline) = &p.tagline %}{% if !tagline.is_empty() %}
|
||||
<div class="movie-tagline">"{{ tagline }}"</div>
|
||||
{% endif %}{% endif %}
|
||||
{% endif %}
|
||||
<div style="margin-top:0.75rem">
|
||||
<a href="/reviews/new" class="btn-small">+ Log a review</a>
|
||||
{% if ctx.user_id.is_some() %}
|
||||
{% if on_watchlist %}
|
||||
<form method="post" action="/watchlist/{{ movie.id().value() }}/remove" style="display:inline">
|
||||
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#4aaa77;border-color:rgba(74,170,119,.3)">✓ On watchlist · Remove</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="/watchlist/add" style="display:inline">
|
||||
<input type="hidden" name="movie_id" value="{{ movie.id().value() }}">
|
||||
<input type="hidden" name="redirect_after" value="/movies/{{ movie.id().value() }}">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small">+ Want to watch</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{# ── Stats ── #}
|
||||
<div class="stats-bar">
|
||||
{% if let Some(avg) = stats.avg_rating %}
|
||||
<div class="stat-box">
|
||||
@@ -47,6 +79,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if let Some(p) = profile %}
|
||||
|
||||
{# ── Overview ── #}
|
||||
{% if let Some(overview) = &p.overview %}{% if !overview.is_empty() %}
|
||||
<p class="movie-overview">{{ overview }}</p>
|
||||
{% endif %}{% endif %}
|
||||
|
||||
{# ── Cast ── #}
|
||||
{% if !p.cast.is_empty() %}
|
||||
<div class="feed-section-label">CAST</div>
|
||||
<div class="cast-strip">
|
||||
{% for (i, member) in p.cast.iter().enumerate() %}{% if i < 10 %}
|
||||
<div class="cast-card">
|
||||
{% if let Some(path) = &member.profile_path %}
|
||||
<img src="https://image.tmdb.org/t/p/w185{{ path }}" alt="{{ member.name }}" loading="lazy">
|
||||
{% else %}
|
||||
<img src="/static/person-placeholder.svg" alt="{{ member.name }}">
|
||||
{% endif %}
|
||||
<div class="cast-name">{{ member.name }}</div>
|
||||
<div class="cast-char">{{ member.character }}</div>
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Crew ── #}
|
||||
{% if !p.crew.is_empty() %}
|
||||
<div class="feed-section-label">CREW</div>
|
||||
<ul class="crew-list">
|
||||
{% for member in &p.crew %}
|
||||
{% if member.job == "Screenplay" || member.job == "Story" || member.job == "Original Music Composer" || member.job == "Director of Photography" %}
|
||||
<li><span class="crew-role">{{ member.job }}</span>{{ member.name }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# ── Reviews ── #}
|
||||
<div class="feed-section-label">REVIEWS</div>
|
||||
<div class="diary">
|
||||
{% for entry in reviews %}
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
</section>
|
||||
<section class="profile-manage">
|
||||
<h3>Account</h3>
|
||||
<a href="/users/{{ profile_user_id }}/watchlist">Watchlist</a>
|
||||
<a href="/settings/profile">Profile settings</a>
|
||||
<a href="/social/blocked">Blocked users</a>
|
||||
{% if ctx.is_admin %}
|
||||
|
||||
68
crates/adapters/template-askama/templates/watchlist.html
Normal file
68
crates/adapters/template-askama/templates/watchlist.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
<div class="entry-title" style="margin-bottom:1rem">Watchlist</div>
|
||||
|
||||
{% if is_owner %}
|
||||
{% if let Some(err) = &error %}
|
||||
<p class="form-error">{{ err }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/watchlist/add" style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1.5rem;align-items:flex-end">
|
||||
<div style="flex:1;min-width:200px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Title or TMDB ID</label>
|
||||
<input type="text" name="query" placeholder='e.g. "Dune" or tmdb:438631' style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<div style="width:90px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Year</label>
|
||||
<input type="number" name="year" placeholder="2021" min="1888" max="2099" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Add</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if display_entries.is_empty() %}
|
||||
<p class="empty">No movies on the watchlist yet.</p>
|
||||
{% else %}
|
||||
<div class="diary">
|
||||
{% for entry in display_entries %}
|
||||
<article class="entry">
|
||||
{% if let Some(url) = &entry.poster_url %}
|
||||
<div class="poster"><img src="{{ url }}" alt=""></div>
|
||||
{% endif %}
|
||||
<div class="entry-body">
|
||||
<div class="entry-title">
|
||||
{% if let Some(url) = &entry.movie_url %}
|
||||
<a href="{{ url }}" class="movie-title-link">{{ entry.movie_title }}</a>
|
||||
{% else %}
|
||||
{{ entry.movie_title }}
|
||||
{% endif %}
|
||||
<span class="year">({{ entry.release_year }})</span>
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.4rem">
|
||||
<span class="feed-time">Added {{ entry.added_at }}</span>
|
||||
</div>
|
||||
{% if let Some(remove_url) = &entry.remove_url %}
|
||||
<form method="post" action="{{ remove_url }}" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="redirect_after" value="/users/{{ owner_id }}/watchlist">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Remove</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<nav class="pagination">
|
||||
{% if current_offset >= limit %}
|
||||
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset - limit }}&limit={{ limit }}" class="page-nav">← Prev</a>
|
||||
{% endif %}
|
||||
{% if has_more %}
|
||||
<a href="/users/{{ owner_id }}/watchlist?offset={{ current_offset + limit }}&limit={{ limit }}" class="page-nav">Next →</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
29
crates/api-types/src/watchlist.rs
Normal file
29
crates/api-types/src/watchlist.rs
Normal 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,
|
||||
}
|
||||
@@ -14,6 +14,7 @@ tokio = { workspace = true }
|
||||
|
||||
[features]
|
||||
xlsx = []
|
||||
federation = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
|
||||
56
crates/application/src/use_cases/add_to_watchlist.rs
Normal file
56
crates/application/src/use_cases/add_to_watchlist.rs
Normal 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(())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
16
crates/application/src/use_cases/get_watchlist.rs
Normal file
16
crates/application/src/use_cases/get_watchlist.rs
Normal 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
|
||||
}
|
||||
12
crates/application/src/use_cases/is_on_watchlist.rs
Normal file
12
crates/application/src/use_cases/is_on_watchlist.rs
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
crates/application/src/use_cases/remove_from_watchlist.rs
Normal file
20
crates/application/src/use_cases/remove_from_watchlist.rs
Normal 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(())
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
crates/domain/src/models/remote_watchlist.rs
Normal file
12
crates/domain/src/models/remote_watchlist.rs
Normal 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>,
|
||||
}
|
||||
31
crates/domain/src/models/watchlist.rs
Normal file
31
crates/domain/src/models/watchlist.rs
Normal 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,
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
19
crates/presentation/src/openapi/watchlist.rs
Normal file
19
crates/presentation/src/openapi/watchlist.rs
Normal 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;
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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![]
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "nats")]
|
||||
use anyhow::Context;
|
||||
use domain::ports::{EventConsumer, EventPublisher};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
static/manifest.json
Normal file
17
static/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Movies Diary",
|
||||
"short_name": "Movies",
|
||||
"description": "Track what you watch, rate and review films.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111111",
|
||||
"theme_color": "#e5c034",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/logo.webp",
|
||||
"sizes": "any",
|
||||
"type": "image/webp",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
static/person-placeholder.svg
Normal file
5
static/person-placeholder.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 96" width="72" height="96">
|
||||
<rect width="72" height="96" rx="6" fill="#2a2a2a"/>
|
||||
<circle cx="36" cy="32" r="14" fill="#444"/>
|
||||
<ellipse cx="36" cy="80" rx="22" ry="18" fill="#444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
@@ -389,7 +389,8 @@ form button[type="submit"]:hover {
|
||||
}
|
||||
|
||||
/* Errors */
|
||||
.error {
|
||||
.error,
|
||||
.form-error {
|
||||
color: #e05050;
|
||||
background: rgba(220, 50, 50, 0.1);
|
||||
padding: 8px 12px;
|
||||
@@ -1037,6 +1038,102 @@ form button[type="submit"]:hover {
|
||||
.hist-bar { background: oklch(85.2% 0.199 91.936); border-radius: 2px; height: 100%; min-width: 1px; }
|
||||
.hist-count { color: rgba(255, 255, 255, 0.45); width: 1.5rem; }
|
||||
|
||||
/* ── Movie detail — profile sections ──────────────────────────────────────── */
|
||||
|
||||
.movie-meta {
|
||||
font-size: 0.82em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.genre-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.genre-pill {
|
||||
font-size: 0.72em;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(229, 192, 52, 0.12);
|
||||
border: 1px solid rgba(229, 192, 52, 0.3);
|
||||
color: oklch(85.2% 0.199 91.936);
|
||||
}
|
||||
|
||||
.movie-tagline {
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.movie-overview {
|
||||
font-size: 0.88em;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cast-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 1.25rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,.15) transparent;
|
||||
}
|
||||
|
||||
.cast-card {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.cast-card img {
|
||||
width: 72px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.cast-name {
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cast-char {
|
||||
font-size: 0.65em;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.crew-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.crew-list li {
|
||||
padding: 0.2rem 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.crew-role {
|
||||
color: var(--text-light);
|
||||
margin-right: 0.4rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.feed-section-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
Reference in New Issue
Block a user