WIP: federation + integrations
Some checks failed
CI / Check / Test (push) Failing after 5m56s

This commit is contained in:
2026-06-02 19:50:19 +02:00
parent dcc9244d4e
commit ac7edd6953
19 changed files with 660 additions and 1352 deletions

View File

@@ -940,21 +940,21 @@ pub fn create_profile_fields_repo(
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
} }
pub async fn wire( pub struct PostgresWireOutput {
database_url: &str, pub pool: PgPool,
) -> anyhow::Result<( pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
sqlx::PgPool, pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
std::sync::Arc<dyn domain::ports::MovieRepository>, pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>, pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::DiaryRepository>, pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>, pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>, pub import_profile: std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>, pub movie_profile: std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>, pub watchlist: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>, pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>, }
std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
)> { pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
use anyhow::Context; use anyhow::Context;
let pool = sqlx::PgPool::connect(database_url) let pool = sqlx::PgPool::connect(database_url)
@@ -967,25 +967,19 @@ pub async fn wire(
.map_err(|e| anyhow::anyhow!("{e}")) .map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?; .context("Database migration failed")?;
let import_session_repo = Ok(PostgresWireOutput {
std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); pool: pool.clone(),
let import_profile_repo = movie: std::sync::Arc::clone(&repo) as _,
std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); review: std::sync::Arc::clone(&repo) as _,
let movie_profile_repo = std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())); diary: std::sync::Arc::clone(&repo) as _,
let watchlist_repo = std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())); stats: std::sync::Arc::clone(&repo) as _,
let ap_content = std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())); user: std::sync::Arc::new(PostgresUserRepository::new(pool.clone())) as _,
import_session: std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()))
Ok(( as _,
pool.clone(), import_profile: std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()))
std::sync::Arc::clone(&repo) as _, as _,
std::sync::Arc::clone(&repo) as _, movie_profile: std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool)) as _,
std::sync::Arc::new(PostgresUserRepository::new(pool)) as _, })
import_session_repo as _,
import_profile_repo as _,
movie_profile_repo as _,
watchlist_repo as _,
ap_content as _,
))
} }

View File

@@ -935,21 +935,21 @@ impl StatsRepository for SqliteMovieRepository {
} }
} }
pub async fn wire( pub struct SqliteWireOutput {
database_url: &str, pub pool: SqlitePool,
) -> anyhow::Result<( pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
sqlx::SqlitePool, pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
std::sync::Arc<dyn domain::ports::MovieRepository>, pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::ReviewRepository>, pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::DiaryRepository>, pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>, pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>, pub import_profile: std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>, pub movie_profile: std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>, pub watchlist: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
std::sync::Arc<dyn domain::ports::MovieProfileRepository>, pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
std::sync::Arc<dyn domain::ports::WatchlistRepository>, }
std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
)> { pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
use anyhow::Context; use anyhow::Context;
use sqlx::sqlite::SqliteConnectOptions; use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr; use std::str::FromStr;
@@ -969,25 +969,19 @@ pub async fn wire(
.map_err(|e| anyhow::anyhow!("{e}")) .map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?; .context("Database migration failed")?;
let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())); Ok(SqliteWireOutput {
let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())); pool: pool.clone(),
let movie_profile_repo = std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())); movie: std::sync::Arc::clone(&repo) as _,
let watchlist_repo = std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())); review: std::sync::Arc::clone(&repo) as _,
let ap_content = std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())); diary: std::sync::Arc::clone(&repo) as _,
stats: std::sync::Arc::clone(&repo) as _,
Ok(( user: std::sync::Arc::new(SqliteUserRepository::new(pool.clone())) as _,
pool.clone(), import_session: std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, import_profile: std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, movie_profile: std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, watchlist: std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())) as _,
std::sync::Arc::clone(&repo) as _, ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool)) as _,
std::sync::Arc::new(SqliteUserRepository::new(pool)) as _, })
import_session_repo as _,
import_profile_repo as _,
movie_profile_repo as _,
watchlist_repo as _,
ap_content as _,
))
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,12 +1,7 @@
use application::ports::{ pub use askama;
ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry,
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, IntegrationsPageData, LoginPageData,
MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData,
RegisterPageData, UsersPageData, WatchQueuePageData, WatchlistPageData, WebhookTokenView,
};
use askama::Template; use askama::Template;
use application::ports::HtmlPageContext;
use chrono::Datelike; use chrono::Datelike;
use domain::models::{ use domain::models::{
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
@@ -28,13 +23,13 @@ mod filters {
} }
} }
struct PageItem { pub struct PageItem {
number: u32, pub number: u32,
is_current: bool, pub is_current: bool,
is_ellipsis: bool, pub is_ellipsis: bool,
} }
fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> { pub fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
if total_pages <= 1 { if total_pages <= 1 {
return vec![]; return vec![];
} }
@@ -67,45 +62,45 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
#[derive(Template)] #[derive(Template)]
#[template(path = "diary.html")] #[template(path = "diary.html")]
struct DiaryTemplate<'a> { pub struct DiaryTemplate<'a> {
entries: &'a [DiaryEntry], pub entries: &'a [DiaryEntry],
current_offset: u32, pub current_offset: u32,
limit: u32, pub limit: u32,
has_more: bool, pub has_more: bool,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
page_items: Vec<PageItem>, pub page_items: Vec<PageItem>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
struct LoginTemplate<'a> { pub struct LoginTemplate<'a> {
error: Option<&'a str>, pub error: Option<&'a str>,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "register.html")] #[template(path = "register.html")]
struct RegisterTemplate<'a> { pub struct RegisterTemplate<'a> {
error: Option<&'a str>, pub error: Option<&'a str>,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "new_review.html")] #[template(path = "new_review.html")]
struct NewReviewTemplate<'a> { pub struct NewReviewTemplate<'a> {
error: Option<&'a str>, pub error: Option<&'a str>,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "activity_feed.html")] #[template(path = "activity_feed.html")]
struct ActivityFeedTemplate<'a> { pub struct ActivityFeedTemplate<'a> {
entries: &'a [FeedEntry], pub entries: &'a [FeedEntry],
current_offset: u32, pub current_offset: u32,
limit: u32, pub limit: u32,
has_more: bool, pub has_more: bool,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
page_items: Vec<PageItem>, pub page_items: Vec<PageItem>,
pub filter: String, pub filter: String,
pub sort_by: String, pub sort_by: String,
pub search: String, pub search: String,
@@ -113,30 +108,30 @@ struct ActivityFeedTemplate<'a> {
#[derive(Template)] #[derive(Template)]
#[template(path = "movie_detail.html")] #[template(path = "movie_detail.html")]
struct MovieDetailTemplate<'a> { pub struct MovieDetailTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
movie: &'a domain::models::Movie, pub movie: &'a domain::models::Movie,
stats: &'a domain::models::MovieStats, pub stats: &'a domain::models::MovieStats,
profile: Option<&'a domain::models::MovieProfile>, pub profile: Option<&'a domain::models::MovieProfile>,
reviews: &'a [domain::models::FeedEntry], pub reviews: &'a [domain::models::FeedEntry],
on_watchlist: bool, pub on_watchlist: bool,
current_offset: u32, pub current_offset: u32,
has_more: bool, pub has_more: bool,
limit: u32, pub limit: u32,
histogram_max: u64, pub histogram_max: u64,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "watchlist.html")] #[template(path = "watchlist.html")]
struct WatchlistTemplate<'a> { pub struct WatchlistTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
owner_id: uuid::Uuid, pub owner_id: uuid::Uuid,
display_entries: &'a [application::ports::WatchlistDisplayEntry], pub display_entries: &'a [application::ports::WatchlistDisplayEntry],
current_offset: u32, pub current_offset: u32,
has_more: bool, pub has_more: bool,
limit: u32, pub limit: u32,
is_owner: bool, pub is_owner: bool,
error: Option<String>, pub error: Option<String>,
} }
impl<'a> ActivityFeedTemplate<'a> { impl<'a> ActivityFeedTemplate<'a> {
@@ -165,53 +160,53 @@ pub struct RemoteActorDisplay {
pub url: String, pub url: String,
} }
struct UserSummaryView { pub struct UserSummaryView {
user_id: uuid::Uuid, pub user_id: uuid::Uuid,
display_name: String, pub display_name: String,
initial: char, pub initial: char,
avg_rating_display: String, pub avg_rating_display: String,
total_movies: i64, pub total_movies: i64,
avatar_url: Option<String>, pub avatar_url: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "users.html")] #[template(path = "users.html")]
struct UsersTemplate<'a> { pub struct UsersTemplate<'a> {
users: Vec<UserSummaryView>, pub users: Vec<UserSummaryView>,
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
remote_actors: Vec<RemoteActorDisplay>, pub remote_actors: Vec<RemoteActorDisplay>,
} }
struct MonthlyRatingRow<'a> { pub struct MonthlyRatingRow<'a> {
rating: &'a MonthlyRating, pub rating: &'a MonthlyRating,
bar_height_px: i64, pub bar_height_px: i64,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "profile.html")] #[template(path = "profile.html")]
struct ProfileTemplate<'a> { pub struct ProfileTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
profile_display_name: String, pub profile_display_name: String,
profile_user_id: uuid::Uuid, pub profile_user_id: uuid::Uuid,
stats: &'a UserStats, pub stats: &'a UserStats,
avg_rating_display: String, pub avg_rating_display: String,
favorite_director_display: String, pub favorite_director_display: String,
most_active_month_display: String, pub most_active_month_display: String,
view: &'a str, pub view: &'a str,
entries: Option<&'a Paginated<DiaryEntry>>, pub entries: Option<&'a Paginated<DiaryEntry>>,
current_offset: u32, pub current_offset: u32,
has_more: bool, pub has_more: bool,
limit: u32, pub limit: u32,
history: Option<&'a Vec<MonthActivity>>, pub history: Option<&'a Vec<MonthActivity>>,
trends: Option<&'a UserTrends>, pub trends: Option<&'a UserTrends>,
monthly_rating_rows: Vec<MonthlyRatingRow<'a>>, pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
heatmap: Vec<HeatmapCell>, pub heatmap: Vec<HeatmapCell>,
page_items: Vec<PageItem>, pub page_items: Vec<PageItem>,
is_own_profile: bool, pub is_own_profile: bool,
error: Option<String>, pub error: Option<String>,
following_count: usize, pub following_count: usize,
followers_count: usize, pub followers_count: usize,
pending_followers: Vec<RemoteActorData>, pub pending_followers: Vec<RemoteActorData>,
pub sort_by: String, pub sort_by: String,
pub search: String, pub search: String,
} }
@@ -235,80 +230,65 @@ impl<'a> ProfileTemplate<'a> {
} }
} }
struct RemoteActorData { pub struct RemoteActorData {
handle: String, pub handle: String,
display_name: Option<String>, pub display_name: Option<String>,
url: String, pub url: String,
avatar_url: Option<String>, pub avatar_url: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "following.html")] #[template(path = "following.html")]
struct FollowingTemplate { pub struct FollowingTemplate {
ctx: HtmlPageContext, pub ctx: HtmlPageContext,
user_id: uuid::Uuid, pub user_id: uuid::Uuid,
actors: Vec<RemoteActorData>, pub actors: Vec<RemoteActorData>,
error: Option<String>, pub error: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "followers.html")] #[template(path = "followers.html")]
struct FollowersTemplate { pub struct FollowersTemplate {
ctx: HtmlPageContext, pub ctx: HtmlPageContext,
user_id: uuid::Uuid, pub user_id: uuid::Uuid,
actors: Vec<RemoteActorData>, pub actors: Vec<RemoteActorData>,
error: Option<String>, pub error: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "blocked_domains.html")] #[template(path = "blocked_domains.html")]
struct BlockedDomainsTemplate<'a> { pub struct BlockedDomainsTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
domains: &'a [BlockedDomainEntry], pub domains: &'a [BlockedDomainEntry],
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "blocked_actors.html")] #[template(path = "blocked_actors.html")]
struct BlockedActorsTemplate<'a> { pub struct BlockedActorsTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
actors: &'a [BlockedActorEntry], pub actors: &'a [BlockedActorEntry],
} }
struct HeatmapCell { pub struct BlockedDomainEntry {
month_label: String, pub domain: String,
count: i64, pub reason: Option<String>,
alpha: f64, pub blocked_at: String,
} }
#[allow(dead_code)] pub struct BlockedActorEntry {
fn relative_time(dt: chrono::NaiveDateTime) -> String { pub url: String,
let now = chrono::Utc::now().naive_utc(); pub handle: String,
let diff = now.signed_duration_since(dt); pub display_name: Option<String>,
if diff.num_seconds() <= 0 { pub avatar_url: Option<String>,
return "just now".to_string();
}
let minutes = diff.num_minutes();
let hours = diff.num_hours();
let days = diff.num_days();
if minutes < 1 {
return "just now".to_string();
}
if minutes < 60 {
return format!("{} min ago", minutes);
}
if hours < 24 {
return format!("{} h ago", hours);
}
if days == 1 {
return "yesterday".to_string();
}
if days < 30 {
return format!("{} days ago", days);
}
dt.format("%b %-d, %Y").to_string()
} }
fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> { pub struct HeatmapCell {
pub month_label: String,
pub count: i64,
pub alpha: f64,
}
pub fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
let current_year = chrono::Utc::now().year(); let current_year = chrono::Utc::now().year();
let count_for = |m: &str| -> i64 { let count_for = |m: &str| -> i64 {
history history
@@ -351,442 +331,97 @@ fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
.collect() .collect()
} }
fn bar_height_px(avg_rating: f64) -> i64 { pub fn bar_height_px(avg_rating: f64) -> i64 {
(avg_rating / 5.0 * 60.0) as i64 (avg_rating / 5.0 * 60.0) as i64
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "profile_settings.html")] #[template(path = "profile_settings.html")]
struct ProfileSettingsTemplate<'a> { pub struct ProfileSettingsTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
bio: Option<&'a str>, pub bio: Option<&'a str>,
avatar_url: Option<&'a str>, pub avatar_url: Option<&'a str>,
banner_url: Option<&'a str>, pub banner_url: Option<&'a str>,
also_known_as: Option<&'a str>, pub also_known_as: Option<&'a str>,
profile_fields: &'a [(String, String)], pub profile_fields: &'a [(String, String)],
saved: bool, pub saved: bool,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "integrations.html")] #[template(path = "integrations.html")]
struct IntegrationsTemplate<'a> { pub struct IntegrationsTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
tokens: &'a [WebhookTokenView], pub tokens: &'a [WebhookTokenView],
webhook_base_url: &'a str, pub webhook_base_url: &'a str,
new_token: Option<&'a str>, pub new_token: Option<&'a str>,
}
pub struct WebhookTokenView {
pub id: String,
pub provider: String,
pub label: Option<String>,
pub created_at: String,
pub last_used_at: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "watch_queue.html")] #[template(path = "watch_queue.html")]
struct WatchQueueTemplate<'a> { pub struct WatchQueueTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
entries: &'a [application::ports::WatchQueueDisplayEntry], pub entries: &'a [WatchQueueDisplayEntry],
error: Option<&'a str>, pub error: Option<&'a str>,
}
pub struct WatchQueueDisplayEntry {
pub id: String,
pub title: String,
pub year: Option<u16>,
pub source: String,
pub watched_at: String,
pub movie_url: Option<String>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "import_upload.html")] #[template(path = "import_upload.html")]
struct ImportUploadTemplate<'a> { pub struct ImportUploadTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
profiles: &'a [ImportProfileView], pub profiles: &'a [ImportProfileView],
error: Option<&'a str>, pub error: Option<&'a str>,
}
pub struct ImportProfileView {
pub id: String,
pub name: String,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "import_mapping.html")] #[template(path = "import_mapping.html")]
struct ImportMappingTemplate<'a> { pub struct ImportMappingTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
session_id: &'a str, pub session_id: &'a str,
columns: &'a [String], pub columns: &'a [String],
sample_rows: &'a [Vec<String>], pub sample_rows: &'a [Vec<String>],
domain_fields: &'a [(&'static str, &'static str)], pub domain_fields: &'a [(&'static str, &'static str)],
error: Option<&'a str>, pub error: Option<&'a str>,
} }
#[derive(Template)] #[derive(Template)]
#[template(path = "import_preview.html")] #[template(path = "import_preview.html")]
struct ImportPreviewTemplate<'a> { pub struct ImportPreviewTemplate<'a> {
ctx: &'a HtmlPageContext, pub ctx: &'a HtmlPageContext,
session_id: &'a str, pub session_id: &'a str,
columns: &'a [String], pub columns: &'a [String],
rows: &'a [ImportPreviewRow], pub rows: &'a [ImportPreviewRow],
} }
#[derive(Default)] pub struct ImportPreviewRow {
pub struct AskamaHtmlRenderer; pub index: usize,
pub status: ImportRowStatus,
impl AskamaHtmlRenderer { pub cells: Vec<String>,
pub fn new() -> Self {
Self {}
}
} }
impl HtmlRenderer for AskamaHtmlRenderer { pub enum ImportRowStatus {
fn render_diary_page( Valid,
&self, Duplicate,
data: &Paginated<DiaryEntry>, Invalid(String),
ctx: HtmlPageContext,
) -> 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.div_ceil(data.limit as u64) as u32;
(tp, data.offset / data.limit)
} else {
(0, 0)
};
DiaryTemplate {
entries: &data.items,
current_offset: data.offset,
limit: data.limit,
has_more,
ctx: &ctx,
page_items: build_page_items(total_pages, current_page),
}
.render()
.map_err(|e| e.to_string())
}
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String> {
LoginTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String> {
RegisterTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String> {
NewReviewTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String> {
let limit = data.limit;
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,
limit,
has_more: data.has_more,
ctx: &data.ctx,
page_items: build_page_items(total_pages, current_page),
filter: data.filter,
sort_by: data.sort_by,
search: data.search,
}
.render()
.map_err(|e| e.to_string())
}
fn render_users_page(&self, data: UsersPageData) -> Result<String, String> {
let users: Vec<UserSummaryView> = data
.users
.iter()
.map(|u| {
let email = u.email();
let display_name = email.split('@').next().unwrap_or(email).to_string();
let initial = display_name
.chars()
.next()
.unwrap_or('?')
.to_ascii_uppercase();
let avg_rating_display = u
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
UserSummaryView {
user_id: u.user_id.value(),
display_name,
initial,
avg_rating_display,
total_movies: u.total_movies,
avatar_url: u.avatar_path.as_ref().map(|p| format!("/images/{}", p)),
}
})
.collect();
let remote_actors = data
.remote_actors
.into_iter()
.map(|a| {
let name = a.display_name.unwrap_or_else(|| a.handle.clone());
let initial = name.chars().next().unwrap_or('?');
RemoteActorDisplay {
display_name: name,
initial,
handle: a.handle,
url: a.url,
}
})
.collect();
UsersTemplate {
users,
ctx: &data.ctx,
remote_actors,
}
.render()
.map_err(|e| e.to_string())
}
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String> {
let heatmap = data
.history
.as_deref()
.map(build_heatmap)
.unwrap_or_default();
let profile_display_name = data
.profile_user_email
.split('@')
.next()
.unwrap_or(&data.profile_user_email)
.to_string();
let monthly_rating_rows: Vec<MonthlyRatingRow<'_>> = data
.trends
.as_ref()
.map(|t| {
t.monthly_ratings
.iter()
.map(|r| MonthlyRatingRow {
bar_height_px: bar_height_px(r.avg_rating),
rating: r,
})
.collect()
})
.unwrap_or_default();
let total_pages = data
.entries
.as_ref()
.map(|e| e.total_count.div_ceil(e.limit.max(1) as u64) as u32)
.unwrap_or(0);
let current_page = data.current_offset.checked_div(data.limit).unwrap_or(0);
let avg_rating_display = data
.stats
.avg_rating
.map(|r| format!("{:.1}", r))
.unwrap_or_else(|| "".to_string());
let favorite_director_display = data
.stats
.favorite_director
.as_deref()
.unwrap_or("")
.to_string();
let most_active_month_display = data
.stats
.most_active_month
.as_deref()
.unwrap_or("")
.to_string();
ProfileTemplate {
ctx: &data.ctx,
profile_display_name,
profile_user_id: data.profile_user_id,
stats: &data.stats,
avg_rating_display,
favorite_director_display,
most_active_month_display,
view: &data.view,
entries: data.entries.as_ref(),
current_offset: data.current_offset,
has_more: data.has_more,
limit: data.limit,
history: data.history.as_ref(),
trends: data.trends.as_ref(),
monthly_rating_rows,
heatmap,
page_items: build_page_items(total_pages, current_page),
is_own_profile: data.is_own_profile,
error: data.error,
following_count: data.following_count,
followers_count: data.followers_count,
pending_followers: data
.pending_followers
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
url: a.url,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect(),
sort_by: data.sort_by.clone(),
search: data.search.clone(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result<String, String> {
MovieDetailTemplate {
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,
histogram_max: data.histogram_max,
}
.render()
.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,
user_id: data.user_id,
actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
avatar_url: a.avatar_url,
})
.collect(),
error: data.error,
}
.render()
.map_err(|e| e.to_string())
}
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String> {
FollowersTemplate {
ctx: data.ctx,
user_id: data.user_id,
actors: data
.actors
.into_iter()
.map(|a| RemoteActorData {
handle: a.handle,
display_name: a.display_name,
url: a.url,
avatar_url: a.avatar_url,
})
.collect(),
error: data.error,
}
.render()
.map_err(|e| e.to_string())
}
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String> {
ImportUploadTemplate {
ctx: &data.ctx,
profiles: &data.profiles,
error: data.error.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String> {
ImportMappingTemplate {
ctx: &data.ctx,
session_id: &data.session_id,
columns: &data.columns,
sample_rows: &data.sample_rows,
domain_fields: &data.domain_fields,
error: data.error.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String> {
ImportPreviewTemplate {
ctx: &data.ctx,
session_id: &data.session_id,
columns: &data.columns,
rows: &data.rows,
}
.render()
.map_err(|e| e.to_string())
}
fn render_profile_settings_page(
&self,
data: ProfileSettingsPageData,
) -> Result<String, String> {
ProfileSettingsTemplate {
ctx: &data.ctx,
bio: data.bio.as_deref(),
avatar_url: data.avatar_url.as_deref(),
banner_url: data.banner_url.as_deref(),
also_known_as: data.also_known_as.as_deref(),
profile_fields: &data.profile_fields,
saved: data.saved,
}
.render()
.map_err(|e| e.to_string())
}
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String> {
BlockedDomainsTemplate {
ctx: &data.ctx,
domains: &data.domains,
}
.render()
.map_err(|e| e.to_string())
}
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String> {
BlockedActorsTemplate {
ctx: &data.ctx,
actors: &data.actors,
}
.render()
.map_err(|e| e.to_string())
}
fn render_integrations_page(&self, data: IntegrationsPageData) -> Result<String, String> {
IntegrationsTemplate {
ctx: &data.ctx,
tokens: &data.tokens,
webhook_base_url: &data.webhook_base_url,
new_token: data.new_token.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result<String, String> {
WatchQueueTemplate {
ctx: &data.ctx,
entries: &data.entries,
error: data.error.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
} }

View File

@@ -13,34 +13,44 @@ use domain::ports::{RemoteWatchlistRepository, SocialQueryPort};
use crate::config::AppConfig; use crate::config::AppConfig;
#[derive(Clone)] #[derive(Clone)]
pub struct AppContext { pub struct Repositories {
pub movie_repository: Arc<dyn MovieRepository>, pub movie: Arc<dyn MovieRepository>,
pub review_repository: Arc<dyn ReviewRepository>, pub review: Arc<dyn ReviewRepository>,
pub diary_repository: Arc<dyn DiaryRepository>, pub diary: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>, pub stats: Arc<dyn StatsRepository>,
pub document_parser: Arc<dyn DocumentParser>, pub user: Arc<dyn UserRepository>,
pub stats_repository: Arc<dyn StatsRepository>, pub import_session: Arc<dyn ImportSessionRepository>,
pub metadata_client: Arc<dyn MetadataClient>, pub import_profile: Arc<dyn ImportProfileRepository>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>, pub movie_profile: Arc<dyn MovieProfileRepository>,
pub image_storage: Arc<dyn ImageStorage>, pub watchlist: Arc<dyn WatchlistRepository>,
pub event_publisher: Arc<dyn EventPublisher>, pub watch_event: Arc<dyn WatchEventRepository>,
pub auth_service: Arc<dyn AuthService>, pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub user_repository: Arc<dyn UserRepository>,
pub import_session_repository: Arc<dyn ImportSessionRepository>,
pub import_profile_repository: Arc<dyn ImportProfileRepository>,
pub movie_profile_repository: Arc<dyn MovieProfileRepository>,
pub person_command: Arc<dyn PersonCommand>, pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>, pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>, pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>, pub search_command: Arc<dyn SearchCommand>,
pub watchlist_repository: Arc<dyn WatchlistRepository>, pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub watch_event_repository: Arc<dyn WatchEventRepository>,
pub webhook_token_repository: Arc<dyn WebhookTokenRepository>,
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>, pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
pub social_query: Arc<dyn SocialQueryPort>, pub social_query: Arc<dyn SocialQueryPort>,
}
#[derive(Clone)]
pub struct Services {
pub auth: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub metadata: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
pub image_storage: Arc<dyn ImageStorage>,
pub event_publisher: Arc<dyn EventPublisher>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
}
#[derive(Clone)]
pub struct AppContext {
pub repos: Repositories,
pub services: Services,
pub config: AppConfig, pub config: AppConfig,
} }

View File

@@ -1,16 +1,6 @@
use uuid::Uuid; use uuid::Uuid;
use domain::models::{ use domain::models::DiaryEntry;
DiaryEntry, FeedEntry, MonthActivity, Movie, MovieProfile, MovieStats, UserStats, UserSummary,
UserTrends, collections::Paginated,
};
pub struct RemoteActorView {
pub handle: String,
pub display_name: Option<String>,
pub url: String,
pub avatar_url: Option<String>,
}
pub struct HtmlPageContext { pub struct HtmlPageContext {
pub user_email: Option<String>, pub user_email: Option<String>,
@@ -30,239 +20,16 @@ impl HtmlPageContext {
} }
} }
pub struct LoginPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub struct RegisterPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub struct NewReviewPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub struct ActivityFeedPageData {
pub ctx: HtmlPageContext,
pub entries: Paginated<FeedEntry>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub filter: String,
pub sort_by: String,
pub search: String,
}
pub struct UsersPageData {
pub ctx: HtmlPageContext,
pub users: Vec<UserSummary>,
pub remote_actors: Vec<RemoteActorView>,
}
pub struct ProfilePageData {
pub ctx: HtmlPageContext,
pub profile_user_id: Uuid,
pub profile_user_email: String,
pub stats: UserStats,
pub view: String,
pub entries: Option<Paginated<DiaryEntry>>,
pub current_offset: u32,
pub has_more: bool,
pub limit: u32,
pub history: Option<Vec<MonthActivity>>,
pub trends: Option<UserTrends>,
pub is_own_profile: bool,
pub error: Option<String>,
pub following_count: usize,
pub followers_count: usize,
pub pending_followers: Vec<RemoteActorView>,
pub sort_by: String,
pub search: String,
}
pub struct FollowingPageData {
pub ctx: HtmlPageContext,
pub user_id: Uuid,
pub actors: Vec<RemoteActorView>,
pub error: Option<String>,
}
pub struct FollowersPageData {
pub ctx: HtmlPageContext,
pub user_id: Uuid,
pub actors: Vec<RemoteActorView>,
pub error: Option<String>,
}
pub struct MovieDetailPageData {
pub ctx: HtmlPageContext,
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)] #[derive(Clone, Debug)]
pub struct WatchlistDisplayEntry { pub struct WatchlistDisplayEntry {
/// Always a full URL: /images/{path} for local, https://... for remote
pub poster_url: Option<String>, pub poster_url: Option<String>,
pub movie_title: String, pub movie_title: String,
pub release_year: u16, pub release_year: u16,
/// /movies/{id} for local; None for remote entries without a local movie record
pub movie_url: Option<String>, pub movie_url: Option<String>,
pub added_at: String, pub added_at: String,
/// /watchlist/{movie_id}/remove for owner; None for remote or non-owner
pub remove_url: Option<String>, 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>,
pub error: Option<String>,
}
pub struct ImportProfileView {
pub id: String,
pub name: String,
}
pub struct ImportMappingPageData {
pub ctx: HtmlPageContext,
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
pub domain_fields: Vec<(&'static str, &'static str)>,
pub error: Option<String>,
}
pub struct ImportPreviewRow {
pub index: usize,
pub status: ImportRowStatus,
pub cells: Vec<String>,
}
pub enum ImportRowStatus {
Valid,
Duplicate,
Invalid(String),
}
pub struct ImportPreviewPageData {
pub ctx: HtmlPageContext,
pub session_id: String,
pub columns: Vec<String>,
pub rows: Vec<ImportPreviewRow>,
}
pub struct ProfileSettingsPageData {
pub ctx: HtmlPageContext,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
pub also_known_as: Option<String>,
pub profile_fields: Vec<(String, String)>,
pub saved: bool,
}
pub struct BlockedDomainEntry {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
pub struct BlockedDomainsPageData {
pub ctx: HtmlPageContext,
pub domains: Vec<BlockedDomainEntry>,
}
pub struct BlockedActorEntry {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
pub struct BlockedActorsPageData {
pub ctx: HtmlPageContext,
pub actors: Vec<BlockedActorEntry>,
}
pub struct WebhookTokenView {
pub id: String,
pub provider: String,
pub label: Option<String>,
pub created_at: String,
pub last_used_at: Option<String>,
}
pub struct IntegrationsPageData {
pub ctx: HtmlPageContext,
pub tokens: Vec<WebhookTokenView>,
pub webhook_base_url: String,
pub new_token: Option<String>,
}
pub struct WatchQueueDisplayEntry {
pub id: String,
pub title: String,
pub year: Option<u16>,
pub source: String,
pub watched_at: String,
pub movie_url: Option<String>,
}
pub struct WatchQueuePageData {
pub ctx: HtmlPageContext,
pub entries: Vec<WatchQueueDisplayEntry>,
pub error: Option<String>,
}
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(
&self,
data: &Paginated<DiaryEntry>,
ctx: HtmlPageContext,
) -> Result<String, String>;
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;
fn render_activity_feed_page(&self, data: ActivityFeedPageData) -> Result<String, String>;
fn render_users_page(&self, data: UsersPageData) -> Result<String, String>;
fn render_profile_page(&self, data: ProfilePageData) -> Result<String, String>;
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>;
fn render_movie_detail_page(&self, data: MovieDetailPageData) -> Result<String, String>;
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
fn render_profile_settings_page(&self, data: ProfileSettingsPageData)
-> 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>;
fn render_integrations_page(&self, data: IntegrationsPageData) -> Result<String, String>;
fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync { pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>; fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
} }

View File

@@ -23,7 +23,10 @@ use domain::{
}, },
}; };
use crate::{config::AppConfig, context::AppContext}; use crate::{
config::AppConfig,
context::{AppContext, Repositories, Services},
};
pub struct TestContextBuilder { pub struct TestContextBuilder {
pub movie_repo: Arc<dyn MovieRepository>, pub movie_repo: Arc<dyn MovieRepository>,
@@ -125,35 +128,39 @@ impl TestContextBuilder {
pub fn build(self) -> AppContext { pub fn build(self) -> AppContext {
AppContext { AppContext {
movie_repository: self.movie_repo, repos: Repositories {
review_repository: self.review_repo, movie: self.movie_repo,
diary_repository: self.diary_repo, review: self.review_repo,
diary_exporter: self.diary_exporter, diary: self.diary_repo,
document_parser: self.document_parser, stats: self.stats_repo,
stats_repository: self.stats_repo, user: self.user_repo,
metadata_client: self.metadata_client, import_session: self.import_session_repo,
poster_fetcher: self.poster_fetcher, import_profile: self.import_profile_repo,
image_storage: self.image_storage, movie_profile: self.movie_profile_repo,
event_publisher: self.event_publisher, watchlist: self.watchlist_repo,
auth_service: self.auth_service, watch_event: self.watch_event_repo,
password_hasher: self.password_hasher, webhook_token: self.webhook_token_repo,
user_repository: self.user_repo, profile_fields: self.profile_fields_repo,
import_session_repository: self.import_session_repo, person_command: self.person_command,
import_profile_repository: self.import_profile_repo, person_query: self.person_query,
movie_profile_repository: self.movie_profile_repo, search_port: self.search_port,
watchlist_repository: self.watchlist_repo, search_command: self.search_command,
watch_event_repository: self.watch_event_repo, #[cfg(feature = "federation")]
webhook_token_repository: self.webhook_token_repo, remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
profile_fields_repository: self.profile_fields_repo, #[cfg(feature = "federation")]
person_command: self.person_command, social_query: Arc::new(PanicSocialQueryPort),
person_query: self.person_query, },
search_port: self.search_port, services: Services {
search_command: self.search_command, auth: self.auth_service,
password_hasher: self.password_hasher,
metadata: self.metadata_client,
poster_fetcher: self.poster_fetcher,
image_storage: self.image_storage,
event_publisher: self.event_publisher,
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
},
config: self.config, config: self.config,
#[cfg(feature = "federation")]
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
#[cfg(feature = "federation")]
social_query: std::sync::Arc::new(PanicSocialQueryPort),
} }
} }
} }

View File

@@ -28,7 +28,12 @@ where
"Missing or invalid auth token".into(), "Missing or invalid auth token".into(),
)) ))
})?; })?;
let user_id = app_state.app_ctx.auth_service.validate_token(token).await?; let user_id = app_state
.app_ctx
.services
.auth
.validate_token(token)
.await?;
Ok(AuthenticatedUser(user_id)) Ok(AuthenticatedUser(user_id))
} }
} }
@@ -62,7 +67,8 @@ where
}; };
let user_id = app_state let user_id = app_state
.app_ctx .app_ctx
.auth_service .services
.auth
.validate_token(&token) .validate_token(&token)
.await .await
.ok(); .ok();
@@ -83,7 +89,8 @@ where
.ok_or_else(|| Redirect::to("/login").into_response())?; .ok_or_else(|| Redirect::to("/login").into_response())?;
let user_id = app_state let user_id = app_state
.app_ctx .app_ctx
.auth_service .services
.auth
.validate_token(&token) .validate_token(&token)
.await .await
.map_err(|_| Redirect::to("/login").into_response())?; .map_err(|_| Redirect::to("/login").into_response())?;
@@ -106,7 +113,8 @@ where
RequiredCookieUser::from_request_parts(parts, state).await?; RequiredCookieUser::from_request_parts(parts, state).await?;
let user = app_state let user = app_state
.app_ctx .app_ctx
.user_repository .repos
.user
.find_by_id(&user_id) .find_by_id(&user_id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?

View File

@@ -1,36 +1,11 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use domain::ports::{ use domain::ports::{
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository, AuthService, ImageStorage, LocalApContentQuery, MetadataClient, PasswordHasher,
LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, WebhookTokenRepository,
PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository,
WatchlistRepository, WebhookTokenRepository,
}; };
pub struct DatabaseAdapters {
pub movie_repo: Arc<dyn MovieRepository>,
pub review_repo: Arc<dyn ReviewRepository>,
pub diary_repo: Arc<dyn DiaryRepository>,
pub stats_repo: Arc<dyn StatsRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub import_session_repo: Arc<dyn ImportSessionRepository>,
pub import_profile_repo: Arc<dyn ImportProfileRepository>,
pub movie_profile_repo: Arc<dyn MovieProfileRepository>,
pub watchlist_repo: Arc<dyn WatchlistRepository>,
pub ap_content_repo: Arc<dyn LocalApContentQuery>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub watch_event_repo: Arc<dyn WatchEventRepository>,
pub webhook_token_repo: Arc<dyn WebhookTokenRepository>,
pub db_pool: DbPool,
}
pub enum DbPool { pub enum DbPool {
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool), Sqlite(sqlx::SqlitePool),
@@ -38,72 +13,94 @@ pub enum DbPool {
Postgres(sqlx::PgPool), Postgres(sqlx::PgPool),
} }
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseAdapters> { pub struct DatabaseOutput {
pub movie: Arc<dyn domain::ports::MovieRepository>,
pub review: Arc<dyn domain::ports::ReviewRepository>,
pub diary: Arc<dyn domain::ports::DiaryRepository>,
pub stats: Arc<dyn domain::ports::StatsRepository>,
pub user: Arc<dyn domain::ports::UserRepository>,
pub import_session: Arc<dyn domain::ports::ImportSessionRepository>,
pub import_profile: Arc<dyn domain::ports::ImportProfileRepository>,
pub movie_profile: Arc<dyn domain::ports::MovieProfileRepository>,
pub watchlist: Arc<dyn domain::ports::WatchlistRepository>,
pub watch_event: Arc<dyn WatchEventRepository>,
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub person_command: Arc<dyn domain::ports::PersonCommand>,
pub person_query: Arc<dyn domain::ports::PersonQuery>,
pub search_port: Arc<dyn domain::ports::SearchPort>,
pub search_command: Arc<dyn domain::ports::SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub ap_content: Arc<dyn LocalApContentQuery>,
pub db_pool: DbPool,
}
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseOutput> {
match backend { match backend {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = postgres::wire(url) let w = postgres::wire(url)
.await .await
.context("PostgreSQL connection failed")?; .context("PostgreSQL connection failed")?;
let (pc, pq) = postgres::create_person_adapter(pool.clone()); let (pc, pq) = postgres::create_person_adapter(w.pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone()); let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone());
let pf = postgres::create_profile_fields_repo(pool.clone()); let pf = postgres::create_profile_fields_repo(w.pool.clone());
let we: Arc<dyn WatchEventRepository> = let we: Arc<dyn WatchEventRepository> =
Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone())); Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> = let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone())); postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
Ok(DatabaseAdapters { );
movie_repo: m, Ok(DatabaseOutput {
review_repo: r, movie: w.movie,
diary_repo: d, review: w.review,
stats_repo: s, diary: w.diary,
user_repo: u, stats: w.stats,
import_session_repo: is, user: w.user,
import_profile_repo: ip, import_session: w.import_session,
movie_profile_repo: mp, import_profile: w.import_profile,
watchlist_repo: wl, movie_profile: w.movie_profile,
ap_content_repo: ac, watchlist: w.watchlist,
watch_event: we,
webhook_token: wt,
person_command: pc, person_command: pc,
person_query: pq, person_query: pq,
search_port: sp, search_port: sp,
search_command: sc, search_command: sc,
profile_fields_repo: pf, profile_fields: pf,
watch_event_repo: we, ap_content: w.ap_content,
webhook_token_repo: wt, db_pool: DbPool::Postgres(w.pool),
db_pool: DbPool::Postgres(pool),
}) })
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
_ => { _ => {
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = sqlite::wire(url) let w = sqlite::wire(url)
.await .await
.context("SQLite connection failed")?; .context("SQLite connection failed")?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone()); let (pc, pq) = sqlite::create_person_adapter(w.pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone()); let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone());
let pf = sqlite::create_profile_fields_repo(pool.clone()); let pf = sqlite::create_profile_fields_repo(w.pool.clone());
let we: Arc<dyn WatchEventRepository> = let we: Arc<dyn WatchEventRepository> =
Arc::new(sqlite::SqliteWatchEventRepository::new(pool.clone())); Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> = let wt: Arc<dyn WebhookTokenRepository> =
Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone())); Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
Ok(DatabaseAdapters { Ok(DatabaseOutput {
movie_repo: m, movie: w.movie,
review_repo: r, review: w.review,
diary_repo: d, diary: w.diary,
stats_repo: s, stats: w.stats,
user_repo: u, user: w.user,
import_session_repo: is, import_session: w.import_session,
import_profile_repo: ip, import_profile: w.import_profile,
movie_profile_repo: mp, movie_profile: w.movie_profile,
watchlist_repo: wl, watchlist: w.watchlist,
ap_content_repo: ac, watch_event: we,
webhook_token: wt,
person_command: pc, person_command: pc,
person_query: pq, person_query: pq,
search_port: sp, search_port: sp,
search_command: sc, search_command: sc,
profile_fields_repo: pf, profile_fields: pf,
watch_event_repo: we, ap_content: w.ap_content,
webhook_token_repo: wt, db_pool: DbPool::Sqlite(w.pool),
db_pool: DbPool::Sqlite(pool),
}) })
} }
#[cfg(not(feature = "sqlite"))] #[cfg(not(feature = "sqlite"))]

View File

@@ -13,7 +13,7 @@ pub async fn get_image(
if key.starts_with("http://") || key.starts_with("https://") { if key.starts_with("http://") || key.starts_with("https://") {
return axum::response::Redirect::temporary(&key).into_response(); return axum::response::Redirect::temporary(&key).into_response();
} }
match state.app_ctx.image_storage.get(&key).await { match state.app_ctx.services.image_storage.get(&key).await {
Ok(bytes) => { Ok(bytes) => {
let mime = infer::get(&bytes) let mime = infer::get(&bytes)
.map(|t| t.mime_type()) .map(|t| t.mime_type())

View File

@@ -6,6 +6,7 @@ pub mod forms;
pub mod handlers; pub mod handlers;
pub mod openapi; pub mod openapi;
pub mod ports; pub mod ports;
pub mod render;
pub mod routes; pub mod routes;
pub mod state; pub mod state;

View File

@@ -5,13 +5,14 @@ use anyhow::Context;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::{config::AppConfig, context::AppContext}; use application::{
config::AppConfig,
context::{AppContext, Repositories, Services},
};
use export::ExportAdapter; use export::ExportAdapter;
use importer::ImporterDocumentParser; use importer::ImporterDocumentParser;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
use presentation::{factory, openapi, routes, state::AppState}; use presentation::{factory, openapi, routes, state::AppState};
use rss::RssAdapter;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
@@ -59,24 +60,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let image_storage = factory::build_image_storage()?; let image_storage = factory::build_image_storage()?;
let db = factory::build_database_adapters(&backend, &database_url).await?; let db = factory::build_database_adapters(&backend, &database_url).await?;
let ap_content_repo = db.ap_content;
let movie_repository = db.movie_repo;
let review_repository = db.review_repo;
let diary_repository = db.diary_repo;
let stats_repository = db.stats_repo;
let user_repository = db.user_repo;
let import_session_repository = db.import_session_repo;
let import_profile_repository = db.import_profile_repo;
let movie_profile_repository = db.movie_profile_repo;
let watchlist_repository = db.watchlist_repo;
let ap_content_repo = db.ap_content_repo;
let person_command = db.person_command;
let person_query = db.person_query;
let search_port = db.search_port;
let search_command = db.search_command;
let profile_fields_repo = db.profile_fields_repo;
let watch_event_repository = db.watch_event_repo;
let webhook_token_repository = db.webhook_token_repo;
let db_pool = db.db_pool; let db_pool = db.db_pool;
// Wire up event channel, federation service, and ap_router // Wire up event channel, federation service, and ap_router
@@ -135,7 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
review_store, review_store,
remote_watchlist_repo: remote_watchlist_repo.clone(), remote_watchlist_repo: remote_watchlist_repo.clone(),
local_ap_content: Arc::clone(&ap_content_repo), local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&user_repository), user_repo: Arc::clone(&db.user),
base_url: app_config.base_url.clone(), base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration, allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep), event_publisher: Arc::clone(&ep),
@@ -184,40 +168,43 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let ap_router = axum::Router::new(); let ap_router = axum::Router::new();
let app_ctx = AppContext { let app_ctx = AppContext {
movie_repository, repos: Repositories {
review_repository, movie: db.movie,
diary_repository, review: db.review,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary: db.diary,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>, stats: db.stats,
stats_repository, user: db.user,
metadata_client, import_session: db.import_session,
poster_fetcher, import_profile: db.import_profile,
image_storage, movie_profile: db.movie_profile,
event_publisher: event_publisher_arc, watchlist: db.watchlist,
auth_service, watch_event: db.watch_event,
password_hasher, webhook_token: db.webhook_token,
user_repository, person_command: db.person_command,
import_session_repository, person_query: db.person_query,
import_profile_repository, search_port: db.search_port,
movie_profile_repository, search_command: db.search_command,
watchlist_repository, profile_fields: db.profile_fields,
watch_event_repository, #[cfg(feature = "federation")]
webhook_token_repository, remote_watchlist: remote_watchlist_repo,
profile_fields_repository: profile_fields_repo, #[cfg(feature = "federation")]
#[cfg(feature = "federation")] social_query: social_query.clone(),
remote_watchlist_repository: remote_watchlist_repo, },
#[cfg(feature = "federation")] services: Services {
social_query: social_query.clone(), auth: auth_service,
person_command, password_hasher,
person_query, metadata: metadata_client,
search_port, poster_fetcher,
search_command, image_storage,
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
},
config: app_config, config: app_config,
}; };
let state = AppState { let state = AppState {
app_ctx, app_ctx,
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new( rss_renderer: Arc::new(RssAdapter::new(
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
)), )),

View File

@@ -1,2 +1 @@
pub use application::ports::HtmlRenderer;
pub use application::ports::RssFeedRenderer; pub use application::ports::RssFeedRenderer;

View File

@@ -0,0 +1,14 @@
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
pub fn render_page(template: impl template_askama::askama::Template) -> Response {
match template.render() {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}

View File

@@ -2,12 +2,11 @@ use std::sync::Arc;
use application::context::AppContext; use application::context::AppContext;
use crate::ports::{HtmlRenderer, RssFeedRenderer}; use crate::ports::RssFeedRenderer;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub app_ctx: AppContext, pub app_ctx: AppContext,
pub html_renderer: Arc<dyn HtmlRenderer>,
pub rss_renderer: Arc<dyn RssFeedRenderer>, pub rss_renderer: Arc<dyn RssFeedRenderer>,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
pub ap_service: Arc<dyn activitypub::ActivityPubPort>, pub ap_service: Arc<dyn activitypub::ActivityPubPort>,

View File

@@ -68,7 +68,7 @@ impl domain::ports::PersonQuery for PersonQueryStub {
async fn search_endpoint_returns_200_with_empty_results() { async fn search_endpoint_returns_200_with_empty_results() {
let mut state = make_test_state(Arc::new(Panic)); let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub // Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub); state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let app = Router::new() let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search)) .route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state); .with_state(state);
@@ -90,7 +90,7 @@ async fn search_endpoint_returns_200_with_empty_results() {
async fn search_endpoint_with_no_query_returns_200() { async fn search_endpoint_with_no_query_returns_200() {
let mut state = make_test_state(Arc::new(Panic)); let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub // Override the search_port with our stub
state.app_ctx.search_port = Arc::new(SearchPortStub); state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let app = Router::new() let app = Router::new()
.route("/api/v1/search", get(crate::handlers::api::get_search)) .route("/api/v1/search", get(crate::handlers::api::get_search))
.with_state(state); .with_state(state);
@@ -114,7 +114,7 @@ async fn search_endpoint_with_no_query_returns_200() {
async fn person_endpoint_returns_404_for_unknown_id() { async fn person_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic)); let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub // Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub); state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/people/{id}", "/api/v1/people/{id}",
@@ -140,7 +140,7 @@ async fn person_endpoint_returns_404_for_unknown_id() {
async fn person_credits_endpoint_returns_404_for_unknown_id() { async fn person_credits_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic)); let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub // Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub); state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
let app = Router::new() let app = Router::new()
.route( .route(
"/api/v1/people/{id}/credits", "/api/v1/people/{id}/credits",

View File

@@ -1,5 +1,8 @@
use super::*; use super::*;
use application::{config::AppConfig, context::AppContext}; use application::{
config::AppConfig,
context::{AppContext, Repositories, Services},
};
use axum::{ use axum::{
Router, Router,
body::Body, body::Body,
@@ -387,120 +390,6 @@ impl domain::ports::DocumentParser for Panic {
} }
} }
impl crate::ports::HtmlRenderer for Panic {
fn render_diary_page(
&self,
_: &Paginated<DiaryEntry>,
_: application::ports::HtmlPageContext,
) -> Result<String, String> {
panic!()
}
fn render_login_page(
&self,
_: application::ports::LoginPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_register_page(
&self,
_: application::ports::RegisterPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_new_review_page(
&self,
_: application::ports::NewReviewPageData<'_>,
) -> Result<String, String> {
panic!()
}
fn render_activity_feed_page(
&self,
_: application::ports::ActivityFeedPageData,
) -> Result<String, String> {
panic!()
}
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> {
panic!()
}
fn render_profile_page(
&self,
_: application::ports::ProfilePageData,
) -> Result<String, String> {
panic!()
}
fn render_following_page(
&self,
_: application::ports::FollowingPageData,
) -> Result<String, String> {
panic!()
}
fn render_followers_page(
&self,
_: application::ports::FollowersPageData,
) -> Result<String, String> {
panic!()
}
fn render_movie_detail_page(
&self,
_: application::ports::MovieDetailPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_upload_page(
&self,
_: application::ports::ImportUploadPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_mapping_page(
&self,
_: application::ports::ImportMappingPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_preview_page(
&self,
_: application::ports::ImportPreviewPageData,
) -> Result<String, String> {
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!()
}
fn render_integrations_page(
&self,
_: application::ports::IntegrationsPageData,
) -> Result<String, String> {
panic!()
}
fn render_watch_queue_page(
&self,
_: application::ports::WatchQueuePageData,
) -> Result<String, String> {
panic!()
}
}
impl crate::ports::RssFeedRenderer for Panic { impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
panic!() panic!()
@@ -660,41 +549,44 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
let repo = Arc::new(Panic); let repo = Arc::new(Panic);
crate::state::AppState { crate::state::AppState {
app_ctx: AppContext { app_ctx: AppContext {
movie_repository: Arc::clone(&repo) as _, repos: Repositories {
review_repository: Arc::clone(&repo) as _, movie: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, review: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _, diary: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _, stats: Arc::clone(&repo) as _,
stats_repository: Arc::clone(&repo) as _, user: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _, import_session: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _, import_profile: Arc::clone(&repo) as _,
image_storage: Arc::clone(&repo) as _, movie_profile: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _, watchlist: Arc::clone(&repo) as _,
password_hasher: Arc::clone(&repo) as _, watch_event: Arc::clone(&repo) as _,
user_repository: Arc::clone(&repo) as _, webhook_token: Arc::clone(&repo) as _,
import_session_repository: Arc::clone(&repo) as _, profile_fields: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _, person_command: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _, person_query: Arc::clone(&repo) as _,
watchlist_repository: Arc::clone(&repo) as _, search_port: Arc::clone(&repo) as _,
watch_event_repository: Arc::clone(&repo) as _, search_command: Arc::clone(&repo) as _,
webhook_token_repository: Arc::clone(&repo) as _, #[cfg(feature = "federation")]
profile_fields_repository: Arc::clone(&repo) as _, remote_watchlist: Arc::clone(&repo) as _,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: Arc::clone(&repo) as _, social_query: Arc::clone(&repo) as _,
#[cfg(feature = "federation")] },
social_query: Arc::clone(&repo) as _, services: Services {
person_command: Arc::clone(&repo) as _, auth: auth_service,
person_query: Arc::clone(&repo) as _, password_hasher: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _, metadata: Arc::clone(&repo) as _,
search_command: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _,
auth_service, image_storage: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
},
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,
base_url: "http://localhost:3000".to_string(), base_url: "http://localhost:3000".to_string(),
rate_limit: 20, rate_limit: 20,
}, },
}, },
html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(Panic), rss_renderer: Arc::new(Panic),
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService), ap_service: Arc::new(activitypub::NoopActivityPubService),

View File

@@ -1,6 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
use application::{config::AppConfig, context::AppContext}; use application::{
config::AppConfig,
context::{AppContext, Repositories, Services},
};
use async_trait::async_trait; use async_trait::async_trait;
use axum::{ use axum::{
Router, Router,
@@ -26,7 +29,6 @@ use presentation::{routes, state::AppState};
use rss::RssAdapter; use rss::RssAdapter;
use sqlite::SqliteMovieRepository; use sqlite::SqliteMovieRepository;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use template_askama::AskamaHtmlRenderer;
use tower::ServiceExt; use tower::ServiceExt;
struct NoopEventPublisher; struct NoopEventPublisher;
@@ -394,41 +396,44 @@ async fn test_app() -> Router {
let repo = Arc::new(repo); let repo = Arc::new(repo);
let state = AppState { let state = AppState {
app_ctx: AppContext { app_ctx: AppContext {
movie_repository: Arc::clone(&repo) as _, repos: Repositories {
review_repository: Arc::clone(&repo) as _, movie: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _, review: Arc::clone(&repo) as _,
diary_exporter: Arc::new(PanicExporter), diary: Arc::clone(&repo) as _,
document_parser: Arc::new(PanicDocumentParser), stats: Arc::clone(&repo) as _,
stats_repository: Arc::clone(&repo) as _, user: Arc::new(NobodyUserRepo),
metadata_client: Arc::new(PanicMeta), import_session: Arc::new(PanicImportSession),
poster_fetcher: Arc::new(PanicFetcher), import_profile: Arc::new(PanicImportProfile),
image_storage: Arc::new(PanicImageStorage), movie_profile: Arc::new(PanicMovieProfile),
event_publisher: Arc::new(NoopEventPublisher), watchlist: Arc::new(PanicWatchlist),
auth_service: Arc::new(PanicAuth), watch_event: Arc::new(domain::testing::PanicWatchEventRepository),
password_hasher: Arc::new(PanicHasher), webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository),
user_repository: Arc::new(NobodyUserRepo), profile_fields: Arc::new(PanicProfileFields),
import_session_repository: Arc::new(PanicImportSession), person_command: Arc::new(PanicPersonCommand),
import_profile_repository: Arc::new(PanicImportProfile), person_query: Arc::new(PanicPersonQuery),
movie_profile_repository: Arc::new(PanicMovieProfile), search_port: Arc::new(PanicSearchPort),
watchlist_repository: Arc::new(PanicWatchlist), search_command: Arc::new(PanicSearchCommand),
watch_event_repository: Arc::new(domain::testing::PanicWatchEventRepository), #[cfg(feature = "federation")]
webhook_token_repository: Arc::new(domain::testing::PanicWebhookTokenRepository), remote_watchlist: Arc::new(PanicRemoteWatchlist),
profile_fields_repository: Arc::new(PanicProfileFields), #[cfg(feature = "federation")]
#[cfg(feature = "federation")] social_query: Arc::new(PanicSocialQuery),
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist), },
#[cfg(feature = "federation")] services: Services {
social_query: Arc::new(PanicSocialQuery), auth: Arc::new(PanicAuth),
person_command: Arc::new(PanicPersonCommand), password_hasher: Arc::new(PanicHasher),
person_query: Arc::new(PanicPersonQuery), metadata: Arc::new(PanicMeta),
search_port: Arc::new(PanicSearchPort), poster_fetcher: Arc::new(PanicFetcher),
search_command: Arc::new(PanicSearchCommand), image_storage: Arc::new(PanicImageStorage),
event_publisher: Arc::new(NoopEventPublisher),
diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser),
},
config: AppConfig { config: AppConfig {
allow_registration: false, allow_registration: false,
base_url: "http://localhost:3000".to_string(), base_url: "http://localhost:3000".to_string(),
rate_limit: 20, rate_limit: 20,
}, },
}, },
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService), ap_service: Arc::new(activitypub::NoopActivityPubService),

View File

@@ -16,7 +16,7 @@ pub enum DbPool {
Postgres(sqlx::PgPool), Postgres(sqlx::PgPool),
} }
pub struct Repos { pub struct WorkerDbOutput {
pub movie: Arc<dyn MovieRepository>, pub movie: Arc<dyn MovieRepository>,
pub review: Arc<dyn ReviewRepository>, pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryRepository>, pub diary: Arc<dyn DiaryRepository>,
@@ -26,96 +26,95 @@ pub struct Repos {
pub import_profile: Arc<dyn ImportProfileRepository>, pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>, pub movie_profile: Arc<dyn MovieProfileRepository>,
pub watchlist: Arc<dyn WatchlistRepository>, pub watchlist: Arc<dyn WatchlistRepository>,
pub ap_content: Arc<dyn LocalApContentQuery>, pub watch_event: Arc<dyn WatchEventRepository>,
pub image_ref_command: Arc<dyn ImageRefCommand>, pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub person_command: Arc<dyn PersonCommand>, pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>, pub person_query: Arc<dyn PersonQuery>,
pub search_command: Arc<dyn SearchCommand>, pub search_command: Arc<dyn SearchCommand>,
pub search_port: Arc<dyn SearchPort>, pub search_port: Arc<dyn SearchPort>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>, pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub watch_event: Arc<dyn WatchEventRepository>, pub ap_content: Arc<dyn LocalApContentQuery>,
pub webhook_token: Arc<dyn WebhookTokenRepository>, pub image_ref_command: Arc<dyn ImageRefCommand>,
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub db_pool: DbPool,
} }
pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> { pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<WorkerDbOutput> {
match backend { match backend {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = postgres::wire(database_url) let w = postgres::wire(database_url)
.await .await
.context("PostgreSQL connection failed")?; .context("PostgreSQL connection failed")?;
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone()); let (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone());
let (person_command, person_query) = postgres::create_person_adapter(pool.clone()); let (person_command, person_query) = postgres::create_person_adapter(w.pool.clone());
let (search_command, search_port) = let (search_command, search_port) =
postgres_search::create_search_adapter(pool.clone()); postgres_search::create_search_adapter(w.pool.clone());
let pf = postgres::create_profile_fields_repo(pool.clone()); let pf = postgres::create_profile_fields_repo(w.pool.clone());
let we: Arc<dyn WatchEventRepository> = let we: Arc<dyn WatchEventRepository> =
Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone())); Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> = let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone())); postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
Ok(( );
Repos { Ok(WorkerDbOutput {
movie: m, movie: w.movie,
review: r, review: w.review,
diary: d, diary: w.diary,
stats: s, stats: w.stats,
user: u, user: w.user,
import_session: is, import_session: w.import_session,
import_profile: ip, import_profile: w.import_profile,
movie_profile: mp, movie_profile: w.movie_profile,
watchlist: wl, watchlist: w.watchlist,
ap_content: ac, watch_event: we,
image_ref_command, webhook_token: wt,
image_ref_query, person_command,
person_command, person_query,
person_query, search_command,
search_command, search_port,
search_port, profile_fields: pf,
profile_fields: pf, ap_content: w.ap_content,
watch_event: we, image_ref_command,
webhook_token: wt, image_ref_query,
}, db_pool: DbPool::Postgres(w.pool),
DbPool::Postgres(pool), })
))
} }
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
_ => { _ => {
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = sqlite::wire(database_url) let w = sqlite::wire(database_url)
.await .await
.context("SQLite connection failed")?; .context("SQLite connection failed")?;
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone()); let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone());
let (person_command, person_query) = sqlite::create_person_adapter(pool.clone()); let (person_command, person_query) = sqlite::create_person_adapter(w.pool.clone());
let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); let (search_command, search_port) =
let pf = sqlite::create_profile_fields_repo(pool.clone()); sqlite_search::create_search_adapter(w.pool.clone());
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
let we: Arc<dyn WatchEventRepository> = let we: Arc<dyn WatchEventRepository> =
Arc::new(sqlite::SqliteWatchEventRepository::new(pool.clone())); Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> = let wt: Arc<dyn WebhookTokenRepository> =
Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone())); Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
Ok(( Ok(WorkerDbOutput {
Repos { movie: w.movie,
movie: m, review: w.review,
review: r, diary: w.diary,
diary: d, stats: w.stats,
stats: s, user: w.user,
user: u, import_session: w.import_session,
import_session: is, import_profile: w.import_profile,
import_profile: ip, movie_profile: w.movie_profile,
movie_profile: mp, watchlist: w.watchlist,
watchlist: wl, watch_event: we,
ap_content: ac, webhook_token: wt,
image_ref_command, person_command,
image_ref_query, person_query,
person_command, search_command,
person_query, search_port,
search_command, profile_fields: pf,
search_port, ap_content: w.ap_content,
profile_fields: pf, image_ref_command,
watch_event: we, image_ref_query,
webhook_token: wt, db_pool: DbPool::Sqlite(w.pool),
}, })
DbPool::Sqlite(pool),
))
} }
#[cfg(not(feature = "sqlite"))] #[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"), _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"),

View File

@@ -6,7 +6,9 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use application::{ use application::{
MovieDiscoveryIndexer, SearchCleanupHandler, config::AppConfig, context::AppContext, MovieDiscoveryIndexer, SearchCleanupHandler,
config::AppConfig,
context::{AppContext, Repositories, Services},
worker::WorkerService, worker::WorkerService,
}; };
use export::ExportAdapter; use export::ExportAdapter;
@@ -34,22 +36,16 @@ async fn main() -> anyhow::Result<()> {
let poster_fetcher = poster_fetcher::create()?; let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?; let image_storage = image_storage::create()?;
let (repos, db_pool) = db::connect(&database_url, &backend).await?; let db = db::connect(&database_url, &backend).await?;
let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?; let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).await?;
let image_ref_command = Arc::clone(&repos.image_ref_command); let image_ref_command = Arc::clone(&db.image_ref_command);
let image_ref_query = Arc::clone(&repos.image_ref_query); let image_ref_query = Arc::clone(&db.image_ref_query);
let person_command = Arc::clone(&repos.person_command);
let person_query = Arc::clone(&repos.person_query);
let search_command = Arc::clone(&repos.search_command);
let search_port = Arc::clone(&repos.search_port);
let profile_fields_repo = Arc::clone(&repos.profile_fields);
// Clone refs federation handler needs before ctx consumes them.
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (fed_ap_content, fed_user_repo, base_url, allow_registration) = ( let (fed_ap_content, fed_user_repo, base_url, allow_registration) = (
Arc::clone(&repos.ap_content), Arc::clone(&db.ap_content),
Arc::clone(&repos.user), Arc::clone(&db.user),
app_config.base_url.clone(), app_config.base_url.clone(),
app_config.allow_registration, app_config.allow_registration,
); );
@@ -63,7 +59,7 @@ async fn main() -> anyhow::Result<()> {
fed_social_query, fed_social_query,
fed_review_store, fed_review_store,
fed_remote_watchlist_repo, fed_remote_watchlist_repo,
) = match &db_pool { ) = match &db.db_pool {
#[cfg(feature = "sqlite-federation")] #[cfg(feature = "sqlite-federation")]
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")] #[cfg(feature = "postgres-federation")]
@@ -71,34 +67,38 @@ async fn main() -> anyhow::Result<()> {
}; };
let ctx = AppContext { let ctx = AppContext {
movie_repository: repos.movie, repos: Repositories {
review_repository: repos.review, movie: db.movie,
diary_repository: repos.diary, review: db.review,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>, diary: db.diary,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>, stats: db.stats,
stats_repository: repos.stats, user: db.user,
metadata_client, import_session: db.import_session,
poster_fetcher, import_profile: db.import_profile,
image_storage, movie_profile: db.movie_profile,
event_publisher: event_publisher_arc, watchlist: db.watchlist,
auth_service, watch_event: db.watch_event,
password_hasher, webhook_token: db.webhook_token,
user_repository: repos.user, profile_fields: db.profile_fields,
import_session_repository: repos.import_session, person_command: db.person_command,
import_profile_repository: repos.import_profile, person_query: db.person_query,
movie_profile_repository: repos.movie_profile, search_port: db.search_port,
watchlist_repository: repos.watchlist, search_command: db.search_command,
watch_event_repository: repos.watch_event, #[cfg(feature = "federation")]
webhook_token_repository: repos.webhook_token, remote_watchlist: fed_remote_watchlist_repo.clone(),
profile_fields_repository: Arc::clone(&profile_fields_repo), #[cfg(feature = "federation")]
#[cfg(feature = "federation")] social_query: fed_social_query,
remote_watchlist_repository: fed_remote_watchlist_repo.clone(), },
#[cfg(feature = "federation")] services: Services {
social_query: fed_social_query, auth: auth_service,
person_command: Arc::clone(&person_command), password_hasher,
person_query: Arc::clone(&person_query), metadata: metadata_client,
search_port: Arc::clone(&search_port), poster_fetcher,
search_command: Arc::clone(&search_command), image_storage,
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
},
config: app_config, config: app_config,
}; };
@@ -113,10 +113,10 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("TMDb enrichment enabled"); tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler { let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
enrichment_client: Arc::new(client), enrichment_client: Arc::new(client),
movie_repository: Arc::clone(&ctx.movie_repository), movie_repository: Arc::clone(&ctx.repos.movie),
profile_repo: Arc::clone(&ctx.movie_profile_repository), profile_repo: Arc::clone(&ctx.repos.movie_profile),
person_command: Arc::clone(&ctx.person_command), person_command: Arc::clone(&ctx.repos.person_command),
search_command: Arc::clone(&ctx.search_command), search_command: Arc::clone(&ctx.repos.search_command),
}) as Arc<dyn EventHandler>; }) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone())) let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>; as Arc<dyn PeriodicJob>;
@@ -131,10 +131,10 @@ async fn main() -> anyhow::Result<()> {
// ── Image conversion ────────────────────────────────────────────────────── // ── Image conversion ──────────────────────────────────────────────────────
let conversion = image_converter::build( let conversion = image_converter::build(
Arc::clone(&ctx.image_storage), Arc::clone(&ctx.services.image_storage),
image_ref_command, image_ref_command,
image_ref_query, image_ref_query,
Arc::clone(&ctx.event_publisher), Arc::clone(&ctx.services.event_publisher),
)?; )?;
// ── Periodic jobs ───────────────────────────────────────────────────────── // ── Periodic jobs ─────────────────────────────────────────────────────────
@@ -166,27 +166,27 @@ async fn main() -> anyhow::Result<()> {
let handlers: Vec<Arc<dyn EventHandler>> = { let handlers: Vec<Arc<dyn EventHandler>> = {
let poster = Arc::new(poster_sync::PosterSyncHandler::new( let poster = Arc::new(poster_sync::PosterSyncHandler::new(
Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.repos.movie),
Arc::clone(&ctx.metadata_client), Arc::clone(&ctx.services.metadata),
Arc::clone(&ctx.poster_fetcher), Arc::clone(&ctx.services.poster_fetcher),
Arc::clone(&ctx.image_storage), Arc::clone(&ctx.services.image_storage),
Arc::clone(&ctx.event_publisher), Arc::clone(&ctx.services.event_publisher),
3, 3,
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(Arc::clone( let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(Arc::clone(
&ctx.image_storage, &ctx.services.image_storage,
))) as Arc<dyn EventHandler>; ))) as Arc<dyn EventHandler>;
#[cfg(not(feature = "federation"))] #[cfg(not(feature = "federation"))]
{ {
let search_cleanup = Arc::new(SearchCleanupHandler::new( let search_cleanup = Arc::new(SearchCleanupHandler::new(
Arc::clone(&ctx.search_command), Arc::clone(&ctx.repos.search_command),
Arc::clone(&ctx.person_query), Arc::clone(&ctx.repos.person_query),
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(
Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.repos.movie),
Arc::clone(&ctx.search_command), Arc::clone(&ctx.repos.search_command),
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
let mut h = vec![poster, cleanup, search_cleanup, discovery_indexer]; let mut h = vec![poster, cleanup, search_cleanup, discovery_indexer];
if let Some(e) = enrichment_handler { if let Some(e) = enrichment_handler {
@@ -211,7 +211,7 @@ async fn main() -> anyhow::Result<()> {
user_repo: fed_user_repo, user_repo: fed_user_repo,
base_url, base_url,
allow_registration, allow_registration,
event_publisher: Arc::clone(&ctx.event_publisher), event_publisher: Arc::clone(&ctx.services.event_publisher),
}) })
.await?; .await?;
@@ -221,12 +221,12 @@ async fn main() -> anyhow::Result<()> {
}) as Arc<dyn EventHandler>; }) as Arc<dyn EventHandler>;
let search_cleanup = Arc::new(SearchCleanupHandler::new( let search_cleanup = Arc::new(SearchCleanupHandler::new(
Arc::clone(&ctx.search_command), Arc::clone(&ctx.repos.search_command),
Arc::clone(&ctx.person_query), Arc::clone(&ctx.repos.person_query),
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new( let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(
Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.repos.movie),
Arc::clone(&ctx.search_command), Arc::clone(&ctx.repos.search_command),
)) as Arc<dyn EventHandler>; )) as Arc<dyn EventHandler>;
tracing::info!("federation event handler registered"); tracing::info!("federation event handler registered");
let mut h = vec![ let mut h = vec![