This commit is contained in:
@@ -940,21 +940,21 @@ pub fn create_profile_fields_repo(
|
||||
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
|
||||
}
|
||||
|
||||
pub async fn wire(
|
||||
database_url: &str,
|
||||
) -> anyhow::Result<(
|
||||
sqlx::PgPool,
|
||||
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
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>,
|
||||
std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
)> {
|
||||
pub struct PostgresWireOutput {
|
||||
pub pool: PgPool,
|
||||
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
pub import_profile: std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
|
||||
pub movie_profile: std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
pub watchlist: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
use anyhow::Context;
|
||||
|
||||
let pool = sqlx::PgPool::connect(database_url)
|
||||
@@ -967,25 +967,19 @@ pub async fn wire(
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("Database migration failed")?;
|
||||
|
||||
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()));
|
||||
let ap_content = std::sync::Arc::new(PostgresApContentQuery::new(pool.clone()));
|
||||
|
||||
Ok((
|
||||
pool.clone(),
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) 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 _,
|
||||
))
|
||||
Ok(PostgresWireOutput {
|
||||
pool: pool.clone(),
|
||||
movie: std::sync::Arc::clone(&repo) as _,
|
||||
review: std::sync::Arc::clone(&repo) as _,
|
||||
diary: std::sync::Arc::clone(&repo) as _,
|
||||
stats: std::sync::Arc::clone(&repo) as _,
|
||||
user: std::sync::Arc::new(PostgresUserRepository::new(pool.clone())) as _,
|
||||
import_session: std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone()))
|
||||
as _,
|
||||
import_profile: std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone()))
|
||||
as _,
|
||||
movie_profile: std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())) as _,
|
||||
watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _,
|
||||
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool)) as _,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -935,21 +935,21 @@ impl StatsRepository for SqliteMovieRepository {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wire(
|
||||
database_url: &str,
|
||||
) -> anyhow::Result<(
|
||||
sqlx::SqlitePool,
|
||||
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
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>,
|
||||
std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
)> {
|
||||
pub struct SqliteWireOutput {
|
||||
pub pool: SqlitePool,
|
||||
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
pub import_profile: std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
|
||||
pub movie_profile: std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
pub watchlist: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
|
||||
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
||||
use anyhow::Context;
|
||||
use sqlx::sqlite::SqliteConnectOptions;
|
||||
use std::str::FromStr;
|
||||
@@ -969,25 +969,19 @@ pub async fn wire(
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("Database migration failed")?;
|
||||
|
||||
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()));
|
||||
let ap_content = std::sync::Arc::new(SqliteApContentQuery::new(pool.clone()));
|
||||
|
||||
Ok((
|
||||
pool.clone(),
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) as _,
|
||||
std::sync::Arc::clone(&repo) 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 _,
|
||||
))
|
||||
Ok(SqliteWireOutput {
|
||||
pool: pool.clone(),
|
||||
movie: std::sync::Arc::clone(&repo) as _,
|
||||
review: std::sync::Arc::clone(&repo) as _,
|
||||
diary: std::sync::Arc::clone(&repo) as _,
|
||||
stats: std::sync::Arc::clone(&repo) as _,
|
||||
user: std::sync::Arc::new(SqliteUserRepository::new(pool.clone())) as _,
|
||||
import_session: std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())) as _,
|
||||
import_profile: std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())) as _,
|
||||
movie_profile: std::sync::Arc::new(SqliteMovieProfileRepository::new(pool.clone())) as _,
|
||||
watchlist: std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())) as _,
|
||||
ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool)) as _,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
use application::ports::{
|
||||
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,
|
||||
};
|
||||
pub use askama;
|
||||
use askama::Template;
|
||||
|
||||
use application::ports::HtmlPageContext;
|
||||
use chrono::Datelike;
|
||||
use domain::models::{
|
||||
DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends,
|
||||
@@ -28,13 +23,13 @@ mod filters {
|
||||
}
|
||||
}
|
||||
|
||||
struct PageItem {
|
||||
number: u32,
|
||||
is_current: bool,
|
||||
is_ellipsis: bool,
|
||||
pub struct PageItem {
|
||||
pub number: u32,
|
||||
pub is_current: 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 {
|
||||
return vec![];
|
||||
}
|
||||
@@ -67,45 +62,45 @@ fn build_page_items(total_pages: u32, current_page: u32) -> Vec<PageItem> {
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "diary.html")]
|
||||
struct DiaryTemplate<'a> {
|
||||
entries: &'a [DiaryEntry],
|
||||
current_offset: u32,
|
||||
limit: u32,
|
||||
has_more: bool,
|
||||
ctx: &'a HtmlPageContext,
|
||||
page_items: Vec<PageItem>,
|
||||
pub struct DiaryTemplate<'a> {
|
||||
pub entries: &'a [DiaryEntry],
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
pub has_more: bool,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub page_items: Vec<PageItem>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
pub struct LoginTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "register.html")]
|
||||
struct RegisterTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
pub struct RegisterTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "new_review.html")]
|
||||
struct NewReviewTemplate<'a> {
|
||||
error: Option<&'a str>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
pub struct NewReviewTemplate<'a> {
|
||||
pub error: Option<&'a str>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "activity_feed.html")]
|
||||
struct ActivityFeedTemplate<'a> {
|
||||
entries: &'a [FeedEntry],
|
||||
current_offset: u32,
|
||||
limit: u32,
|
||||
has_more: bool,
|
||||
ctx: &'a HtmlPageContext,
|
||||
page_items: Vec<PageItem>,
|
||||
pub struct ActivityFeedTemplate<'a> {
|
||||
pub entries: &'a [FeedEntry],
|
||||
pub current_offset: u32,
|
||||
pub limit: u32,
|
||||
pub has_more: bool,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub page_items: Vec<PageItem>,
|
||||
pub filter: String,
|
||||
pub sort_by: String,
|
||||
pub search: String,
|
||||
@@ -113,30 +108,30 @@ struct ActivityFeedTemplate<'a> {
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "movie_detail.html")]
|
||||
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,
|
||||
pub struct MovieDetailTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub movie: &'a domain::models::Movie,
|
||||
pub stats: &'a domain::models::MovieStats,
|
||||
pub profile: Option<&'a domain::models::MovieProfile>,
|
||||
pub reviews: &'a [domain::models::FeedEntry],
|
||||
pub on_watchlist: bool,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub 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>,
|
||||
pub struct WatchlistTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub owner_id: uuid::Uuid,
|
||||
pub display_entries: &'a [application::ports::WatchlistDisplayEntry],
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub is_owner: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> ActivityFeedTemplate<'a> {
|
||||
@@ -165,53 +160,53 @@ pub struct RemoteActorDisplay {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
struct UserSummaryView {
|
||||
user_id: uuid::Uuid,
|
||||
display_name: String,
|
||||
initial: char,
|
||||
avg_rating_display: String,
|
||||
total_movies: i64,
|
||||
avatar_url: Option<String>,
|
||||
pub struct UserSummaryView {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub display_name: String,
|
||||
pub initial: char,
|
||||
pub avg_rating_display: String,
|
||||
pub total_movies: i64,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users.html")]
|
||||
struct UsersTemplate<'a> {
|
||||
users: Vec<UserSummaryView>,
|
||||
ctx: &'a HtmlPageContext,
|
||||
remote_actors: Vec<RemoteActorDisplay>,
|
||||
pub struct UsersTemplate<'a> {
|
||||
pub users: Vec<UserSummaryView>,
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub remote_actors: Vec<RemoteActorDisplay>,
|
||||
}
|
||||
|
||||
struct MonthlyRatingRow<'a> {
|
||||
rating: &'a MonthlyRating,
|
||||
bar_height_px: i64,
|
||||
pub struct MonthlyRatingRow<'a> {
|
||||
pub rating: &'a MonthlyRating,
|
||||
pub bar_height_px: i64,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "profile.html")]
|
||||
struct ProfileTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
profile_display_name: String,
|
||||
profile_user_id: uuid::Uuid,
|
||||
stats: &'a UserStats,
|
||||
avg_rating_display: String,
|
||||
favorite_director_display: String,
|
||||
most_active_month_display: String,
|
||||
view: &'a str,
|
||||
entries: Option<&'a Paginated<DiaryEntry>>,
|
||||
current_offset: u32,
|
||||
has_more: bool,
|
||||
limit: u32,
|
||||
history: Option<&'a Vec<MonthActivity>>,
|
||||
trends: Option<&'a UserTrends>,
|
||||
monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
heatmap: Vec<HeatmapCell>,
|
||||
page_items: Vec<PageItem>,
|
||||
is_own_profile: bool,
|
||||
error: Option<String>,
|
||||
following_count: usize,
|
||||
followers_count: usize,
|
||||
pending_followers: Vec<RemoteActorData>,
|
||||
pub struct ProfileTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub profile_display_name: String,
|
||||
pub profile_user_id: uuid::Uuid,
|
||||
pub stats: &'a UserStats,
|
||||
pub avg_rating_display: String,
|
||||
pub favorite_director_display: String,
|
||||
pub most_active_month_display: String,
|
||||
pub view: &'a str,
|
||||
pub entries: Option<&'a Paginated<DiaryEntry>>,
|
||||
pub current_offset: u32,
|
||||
pub has_more: bool,
|
||||
pub limit: u32,
|
||||
pub history: Option<&'a Vec<MonthActivity>>,
|
||||
pub trends: Option<&'a UserTrends>,
|
||||
pub monthly_rating_rows: Vec<MonthlyRatingRow<'a>>,
|
||||
pub heatmap: Vec<HeatmapCell>,
|
||||
pub page_items: Vec<PageItem>,
|
||||
pub is_own_profile: bool,
|
||||
pub error: Option<String>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<RemoteActorData>,
|
||||
pub sort_by: String,
|
||||
pub search: String,
|
||||
}
|
||||
@@ -235,80 +230,65 @@ impl<'a> ProfileTemplate<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteActorData {
|
||||
handle: String,
|
||||
display_name: Option<String>,
|
||||
url: String,
|
||||
avatar_url: Option<String>,
|
||||
pub struct RemoteActorData {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "following.html")]
|
||||
struct FollowingTemplate {
|
||||
ctx: HtmlPageContext,
|
||||
user_id: uuid::Uuid,
|
||||
actors: Vec<RemoteActorData>,
|
||||
error: Option<String>,
|
||||
pub struct FollowingTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "followers.html")]
|
||||
struct FollowersTemplate {
|
||||
ctx: HtmlPageContext,
|
||||
user_id: uuid::Uuid,
|
||||
actors: Vec<RemoteActorData>,
|
||||
error: Option<String>,
|
||||
pub struct FollowersTemplate {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub user_id: uuid::Uuid,
|
||||
pub actors: Vec<RemoteActorData>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blocked_domains.html")]
|
||||
struct BlockedDomainsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
domains: &'a [BlockedDomainEntry],
|
||||
pub struct BlockedDomainsTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub domains: &'a [BlockedDomainEntry],
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blocked_actors.html")]
|
||||
struct BlockedActorsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
actors: &'a [BlockedActorEntry],
|
||||
pub struct BlockedActorsTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub actors: &'a [BlockedActorEntry],
|
||||
}
|
||||
|
||||
struct HeatmapCell {
|
||||
month_label: String,
|
||||
count: i64,
|
||||
alpha: f64,
|
||||
pub struct BlockedDomainEntry {
|
||||
pub domain: String,
|
||||
pub reason: Option<String>,
|
||||
pub blocked_at: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn relative_time(dt: chrono::NaiveDateTime) -> String {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let diff = now.signed_duration_since(dt);
|
||||
if diff.num_seconds() <= 0 {
|
||||
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()
|
||||
pub struct BlockedActorEntry {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<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 count_for = |m: &str| -> i64 {
|
||||
history
|
||||
@@ -351,442 +331,97 @@ fn build_heatmap(history: &[MonthActivity]) -> Vec<HeatmapCell> {
|
||||
.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
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "profile_settings.html")]
|
||||
struct ProfileSettingsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
bio: Option<&'a str>,
|
||||
avatar_url: Option<&'a str>,
|
||||
banner_url: Option<&'a str>,
|
||||
also_known_as: Option<&'a str>,
|
||||
profile_fields: &'a [(String, String)],
|
||||
saved: bool,
|
||||
pub struct ProfileSettingsTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub bio: Option<&'a str>,
|
||||
pub avatar_url: Option<&'a str>,
|
||||
pub banner_url: Option<&'a str>,
|
||||
pub also_known_as: Option<&'a str>,
|
||||
pub profile_fields: &'a [(String, String)],
|
||||
pub saved: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "integrations.html")]
|
||||
struct IntegrationsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
tokens: &'a [WebhookTokenView],
|
||||
webhook_base_url: &'a str,
|
||||
new_token: Option<&'a str>,
|
||||
pub struct IntegrationsTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub tokens: &'a [WebhookTokenView],
|
||||
pub webhook_base_url: &'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)]
|
||||
#[template(path = "watch_queue.html")]
|
||||
struct WatchQueueTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
entries: &'a [application::ports::WatchQueueDisplayEntry],
|
||||
error: Option<&'a str>,
|
||||
pub struct WatchQueueTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub entries: &'a [WatchQueueDisplayEntry],
|
||||
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)]
|
||||
#[template(path = "import_upload.html")]
|
||||
struct ImportUploadTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
profiles: &'a [ImportProfileView],
|
||||
error: Option<&'a str>,
|
||||
pub struct ImportUploadTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub profiles: &'a [ImportProfileView],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub struct ImportProfileView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_mapping.html")]
|
||||
struct ImportMappingTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
session_id: &'a str,
|
||||
columns: &'a [String],
|
||||
sample_rows: &'a [Vec<String>],
|
||||
domain_fields: &'a [(&'static str, &'static str)],
|
||||
error: Option<&'a str>,
|
||||
pub struct ImportMappingTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub sample_rows: &'a [Vec<String>],
|
||||
pub domain_fields: &'a [(&'static str, &'static str)],
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_preview.html")]
|
||||
struct ImportPreviewTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
session_id: &'a str,
|
||||
columns: &'a [String],
|
||||
rows: &'a [ImportPreviewRow],
|
||||
pub struct ImportPreviewTemplate<'a> {
|
||||
pub ctx: &'a HtmlPageContext,
|
||||
pub session_id: &'a str,
|
||||
pub columns: &'a [String],
|
||||
pub rows: &'a [ImportPreviewRow],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AskamaHtmlRenderer;
|
||||
|
||||
impl AskamaHtmlRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
pub struct ImportPreviewRow {
|
||||
pub index: usize,
|
||||
pub status: ImportRowStatus,
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
data: &Paginated<DiaryEntry>,
|
||||
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())
|
||||
}
|
||||
pub enum ImportRowStatus {
|
||||
Valid,
|
||||
Duplicate,
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
@@ -13,34 +13,44 @@ use domain::ports::{RemoteWatchlistRepository, SocialQueryPort};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
pub movie_repository: Arc<dyn MovieRepository>,
|
||||
pub review_repository: Arc<dyn ReviewRepository>,
|
||||
pub diary_repository: Arc<dyn DiaryRepository>,
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub stats_repository: Arc<dyn StatsRepository>,
|
||||
pub metadata_client: Arc<dyn MetadataClient>,
|
||||
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
pub image_storage: Arc<dyn ImageStorage>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub auth_service: Arc<dyn AuthService>,
|
||||
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 struct Repositories {
|
||||
pub movie: Arc<dyn MovieRepository>,
|
||||
pub review: Arc<dyn ReviewRepository>,
|
||||
pub diary: Arc<dyn DiaryRepository>,
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub watch_event: Arc<dyn WatchEventRepository>,
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
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 watchlist_repository: Arc<dyn WatchlistRepository>,
|
||||
pub watch_event_repository: Arc<dyn WatchEventRepository>,
|
||||
pub webhook_token_repository: Arc<dyn WebhookTokenRepository>,
|
||||
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
||||
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
|
||||
#[cfg(feature = "federation")]
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::{
|
||||
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>,
|
||||
}
|
||||
use domain::models::DiaryEntry;
|
||||
|
||||
pub struct HtmlPageContext {
|
||||
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)]
|
||||
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>,
|
||||
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 {
|
||||
fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ use domain::{
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{config::AppConfig, context::AppContext};
|
||||
use crate::{
|
||||
config::AppConfig,
|
||||
context::{AppContext, Repositories, Services},
|
||||
};
|
||||
|
||||
pub struct TestContextBuilder {
|
||||
pub movie_repo: Arc<dyn MovieRepository>,
|
||||
@@ -125,35 +128,39 @@ impl TestContextBuilder {
|
||||
|
||||
pub fn build(self) -> AppContext {
|
||||
AppContext {
|
||||
movie_repository: self.movie_repo,
|
||||
review_repository: self.review_repo,
|
||||
diary_repository: self.diary_repo,
|
||||
diary_exporter: self.diary_exporter,
|
||||
document_parser: self.document_parser,
|
||||
stats_repository: self.stats_repo,
|
||||
metadata_client: self.metadata_client,
|
||||
poster_fetcher: self.poster_fetcher,
|
||||
image_storage: self.image_storage,
|
||||
event_publisher: self.event_publisher,
|
||||
auth_service: self.auth_service,
|
||||
password_hasher: self.password_hasher,
|
||||
user_repository: self.user_repo,
|
||||
import_session_repository: self.import_session_repo,
|
||||
import_profile_repository: self.import_profile_repo,
|
||||
movie_profile_repository: self.movie_profile_repo,
|
||||
watchlist_repository: self.watchlist_repo,
|
||||
watch_event_repository: self.watch_event_repo,
|
||||
webhook_token_repository: self.webhook_token_repo,
|
||||
profile_fields_repository: self.profile_fields_repo,
|
||||
person_command: self.person_command,
|
||||
person_query: self.person_query,
|
||||
search_port: self.search_port,
|
||||
search_command: self.search_command,
|
||||
repos: Repositories {
|
||||
movie: self.movie_repo,
|
||||
review: self.review_repo,
|
||||
diary: self.diary_repo,
|
||||
stats: self.stats_repo,
|
||||
user: self.user_repo,
|
||||
import_session: self.import_session_repo,
|
||||
import_profile: self.import_profile_repo,
|
||||
movie_profile: self.movie_profile_repo,
|
||||
watchlist: self.watchlist_repo,
|
||||
watch_event: self.watch_event_repo,
|
||||
webhook_token: self.webhook_token_repo,
|
||||
profile_fields: self.profile_fields_repo,
|
||||
person_command: self.person_command,
|
||||
person_query: self.person_query,
|
||||
search_port: self.search_port,
|
||||
search_command: self.search_command,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: Arc::new(PanicSocialQueryPort),
|
||||
},
|
||||
services: Services {
|
||||
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,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: std::sync::Arc::new(PanicSocialQueryPort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ where
|
||||
"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))
|
||||
}
|
||||
}
|
||||
@@ -62,7 +67,8 @@ where
|
||||
};
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.services
|
||||
.auth
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.ok();
|
||||
@@ -83,7 +89,8 @@ where
|
||||
.ok_or_else(|| Redirect::to("/login").into_response())?;
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.services
|
||||
.auth
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.map_err(|_| Redirect::to("/login").into_response())?;
|
||||
@@ -106,7 +113,8 @@ where
|
||||
RequiredCookieUser::from_request_parts(parts, state).await?;
|
||||
let user = app_state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository,
|
||||
LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher,
|
||||
PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository,
|
||||
WatchlistRepository, WebhookTokenRepository,
|
||||
AuthService, ImageStorage, LocalApContentQuery, MetadataClient, PasswordHasher,
|
||||
PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, 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 {
|
||||
#[cfg(feature = "sqlite")]
|
||||
Sqlite(sqlx::SqlitePool),
|
||||
@@ -38,72 +13,94 @@ pub enum DbPool {
|
||||
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 {
|
||||
#[cfg(feature = "postgres")]
|
||||
"postgres" => {
|
||||
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = postgres::wire(url)
|
||||
let w = postgres::wire(url)
|
||||
.await
|
||||
.context("PostgreSQL connection failed")?;
|
||||
let (pc, pq) = postgres::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(pool.clone());
|
||||
let (pc, pq) = postgres::create_person_adapter(w.pool.clone());
|
||||
let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(w.pool.clone());
|
||||
let we: Arc<dyn WatchEventRepository> =
|
||||
Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> =
|
||||
Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone()));
|
||||
Ok(DatabaseAdapters {
|
||||
movie_repo: m,
|
||||
review_repo: r,
|
||||
diary_repo: d,
|
||||
stats_repo: s,
|
||||
user_repo: u,
|
||||
import_session_repo: is,
|
||||
import_profile_repo: ip,
|
||||
movie_profile_repo: mp,
|
||||
watchlist_repo: wl,
|
||||
ap_content_repo: ac,
|
||||
Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
|
||||
postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
|
||||
);
|
||||
Ok(DatabaseOutput {
|
||||
movie: w.movie,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
person_command: pc,
|
||||
person_query: pq,
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields_repo: pf,
|
||||
watch_event_repo: we,
|
||||
webhook_token_repo: wt,
|
||||
db_pool: DbPool::Postgres(pool),
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
_ => {
|
||||
let (pool, m, r, d, s, u, is, ip, mp, wl, ac) = sqlite::wire(url)
|
||||
let w = sqlite::wire(url)
|
||||
.await
|
||||
.context("SQLite connection failed")?;
|
||||
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
|
||||
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
|
||||
let pf = sqlite::create_profile_fields_repo(pool.clone());
|
||||
let (pc, pq) = sqlite::create_person_adapter(w.pool.clone());
|
||||
let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone());
|
||||
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
|
||||
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> =
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone()));
|
||||
Ok(DatabaseAdapters {
|
||||
movie_repo: m,
|
||||
review_repo: r,
|
||||
diary_repo: d,
|
||||
stats_repo: s,
|
||||
user_repo: u,
|
||||
import_session_repo: is,
|
||||
import_profile_repo: ip,
|
||||
movie_profile_repo: mp,
|
||||
watchlist_repo: wl,
|
||||
ap_content_repo: ac,
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
|
||||
Ok(DatabaseOutput {
|
||||
movie: w.movie,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
person_command: pc,
|
||||
person_query: pq,
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields_repo: pf,
|
||||
watch_event_repo: we,
|
||||
webhook_token_repo: wt,
|
||||
db_pool: DbPool::Sqlite(pool),
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
|
||||
@@ -13,7 +13,7 @@ pub async fn get_image(
|
||||
if key.starts_with("http://") || key.starts_with("https://") {
|
||||
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) => {
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type())
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod forms;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod ports;
|
||||
pub mod render;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ use anyhow::Context;
|
||||
use tokio::net::TcpListener;
|
||||
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 importer::ImporterDocumentParser;
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
|
||||
use presentation::{factory, openapi, routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
|
||||
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 db = factory::build_database_adapters(&backend, &database_url).await?;
|
||||
|
||||
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 ap_content_repo = db.ap_content;
|
||||
let db_pool = db.db_pool;
|
||||
|
||||
// Wire up event channel, federation service, and ap_router
|
||||
@@ -135,7 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
review_store,
|
||||
remote_watchlist_repo: remote_watchlist_repo.clone(),
|
||||
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(),
|
||||
allow_registration: app_config.allow_registration,
|
||||
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 app_ctx = AppContext {
|
||||
movie_repository,
|
||||
review_repository,
|
||||
diary_repository,
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
stats_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
image_storage,
|
||||
event_publisher: event_publisher_arc,
|
||||
auth_service,
|
||||
password_hasher,
|
||||
user_repository,
|
||||
import_session_repository,
|
||||
import_profile_repository,
|
||||
movie_profile_repository,
|
||||
watchlist_repository,
|
||||
watch_event_repository,
|
||||
webhook_token_repository,
|
||||
profile_fields_repository: profile_fields_repo,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: remote_watchlist_repo,
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: social_query.clone(),
|
||||
person_command,
|
||||
person_query,
|
||||
search_port,
|
||||
search_command,
|
||||
repos: Repositories {
|
||||
movie: db.movie,
|
||||
review: db.review,
|
||||
diary: db.diary,
|
||||
stats: db.stats,
|
||||
user: db.user,
|
||||
import_session: db.import_session,
|
||||
import_profile: db.import_profile,
|
||||
movie_profile: db.movie_profile,
|
||||
watchlist: db.watchlist,
|
||||
watch_event: db.watch_event,
|
||||
webhook_token: db.webhook_token,
|
||||
person_command: db.person_command,
|
||||
person_query: db.person_query,
|
||||
search_port: db.search_port,
|
||||
search_command: db.search_command,
|
||||
profile_fields: db.profile_fields,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: remote_watchlist_repo,
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: social_query.clone(),
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
password_hasher,
|
||||
metadata: metadata_client,
|
||||
poster_fetcher,
|
||||
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,
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
app_ctx,
|
||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new(
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
)),
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub use application::ports::HtmlRenderer;
|
||||
pub use application::ports::RssFeedRenderer;
|
||||
|
||||
14
crates/presentation/src/render.rs
Normal file
14
crates/presentation/src/render.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use application::context::AppContext;
|
||||
|
||||
use crate::ports::{HtmlRenderer, RssFeedRenderer};
|
||||
use crate::ports::RssFeedRenderer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub app_ctx: AppContext,
|
||||
pub html_renderer: Arc<dyn HtmlRenderer>,
|
||||
pub rss_renderer: Arc<dyn RssFeedRenderer>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
|
||||
|
||||
@@ -68,7 +68,7 @@ impl domain::ports::PersonQuery for PersonQueryStub {
|
||||
async fn search_endpoint_returns_200_with_empty_results() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// 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()
|
||||
.route("/api/v1/search", get(crate::handlers::api::get_search))
|
||||
.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() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// 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()
|
||||
.route("/api/v1/search", get(crate::handlers::api::get_search))
|
||||
.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() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// 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()
|
||||
.route(
|
||||
"/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() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// 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()
|
||||
.route(
|
||||
"/api/v1/people/{id}/credits",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use application::{
|
||||
config::AppConfig,
|
||||
context::{AppContext, Repositories, Services},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
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 {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
panic!()
|
||||
@@ -660,41 +549,44 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
let repo = Arc::new(Panic);
|
||||
crate::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
movie_repository: Arc::clone(&repo) as _,
|
||||
review_repository: Arc::clone(&repo) as _,
|
||||
diary_repository: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
image_storage: Arc::clone(&repo) as _,
|
||||
event_publisher: Arc::clone(&repo) as _,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
user_repository: Arc::clone(&repo) as _,
|
||||
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 _,
|
||||
watch_event_repository: Arc::clone(&repo) as _,
|
||||
webhook_token_repository: Arc::clone(&repo) as _,
|
||||
profile_fields_repository: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: Arc::clone(&repo) as _,
|
||||
person_command: Arc::clone(&repo) as _,
|
||||
person_query: Arc::clone(&repo) as _,
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
search_command: Arc::clone(&repo) as _,
|
||||
auth_service,
|
||||
repos: Repositories {
|
||||
movie: Arc::clone(&repo) as _,
|
||||
review: Arc::clone(&repo) as _,
|
||||
diary: Arc::clone(&repo) as _,
|
||||
stats: Arc::clone(&repo) as _,
|
||||
user: Arc::clone(&repo) as _,
|
||||
import_session: Arc::clone(&repo) as _,
|
||||
import_profile: Arc::clone(&repo) as _,
|
||||
movie_profile: Arc::clone(&repo) as _,
|
||||
watchlist: Arc::clone(&repo) as _,
|
||||
watch_event: Arc::clone(&repo) as _,
|
||||
webhook_token: Arc::clone(&repo) as _,
|
||||
profile_fields: Arc::clone(&repo) as _,
|
||||
person_command: Arc::clone(&repo) as _,
|
||||
person_query: Arc::clone(&repo) as _,
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
search_command: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: Arc::clone(&repo) as _,
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: Arc::clone(&repo) as _,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
metadata: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
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 {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
},
|
||||
},
|
||||
html_renderer: Arc::new(Panic),
|
||||
rss_renderer: Arc::new(Panic),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use application::{
|
||||
config::AppConfig,
|
||||
context::{AppContext, Repositories, Services},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -26,7 +29,6 @@ use presentation::{routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
use sqlite::SqliteMovieRepository;
|
||||
use sqlx::SqlitePool;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoopEventPublisher;
|
||||
@@ -394,41 +396,44 @@ async fn test_app() -> Router {
|
||||
let repo = Arc::new(repo);
|
||||
let state = AppState {
|
||||
app_ctx: AppContext {
|
||||
movie_repository: Arc::clone(&repo) as _,
|
||||
review_repository: Arc::clone(&repo) as _,
|
||||
diary_repository: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::new(PanicExporter),
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
image_storage: Arc::new(PanicImageStorage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(NobodyUserRepo),
|
||||
import_session_repository: Arc::new(PanicImportSession),
|
||||
import_profile_repository: Arc::new(PanicImportProfile),
|
||||
movie_profile_repository: Arc::new(PanicMovieProfile),
|
||||
watchlist_repository: Arc::new(PanicWatchlist),
|
||||
watch_event_repository: Arc::new(domain::testing::PanicWatchEventRepository),
|
||||
webhook_token_repository: Arc::new(domain::testing::PanicWebhookTokenRepository),
|
||||
profile_fields_repository: Arc::new(PanicProfileFields),
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: Arc::new(PanicSocialQuery),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(PanicPersonQuery),
|
||||
search_port: Arc::new(PanicSearchPort),
|
||||
search_command: Arc::new(PanicSearchCommand),
|
||||
repos: Repositories {
|
||||
movie: Arc::clone(&repo) as _,
|
||||
review: Arc::clone(&repo) as _,
|
||||
diary: Arc::clone(&repo) as _,
|
||||
stats: Arc::clone(&repo) as _,
|
||||
user: Arc::new(NobodyUserRepo),
|
||||
import_session: Arc::new(PanicImportSession),
|
||||
import_profile: Arc::new(PanicImportProfile),
|
||||
movie_profile: Arc::new(PanicMovieProfile),
|
||||
watchlist: Arc::new(PanicWatchlist),
|
||||
watch_event: Arc::new(domain::testing::PanicWatchEventRepository),
|
||||
webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository),
|
||||
profile_fields: Arc::new(PanicProfileFields),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(PanicPersonQuery),
|
||||
search_port: Arc::new(PanicSearchPort),
|
||||
search_command: Arc::new(PanicSearchCommand),
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlist),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: Arc::new(PanicSocialQuery),
|
||||
},
|
||||
services: Services {
|
||||
auth: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
metadata: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
image_storage: Arc::new(PanicImageStorage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
diary_exporter: Arc::new(PanicExporter),
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
},
|
||||
},
|
||||
html_renderer: Arc::new(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
|
||||
@@ -16,7 +16,7 @@ pub enum DbPool {
|
||||
Postgres(sqlx::PgPool),
|
||||
}
|
||||
|
||||
pub struct Repos {
|
||||
pub struct WorkerDbOutput {
|
||||
pub movie: Arc<dyn MovieRepository>,
|
||||
pub review: Arc<dyn ReviewRepository>,
|
||||
pub diary: Arc<dyn DiaryRepository>,
|
||||
@@ -26,96 +26,95 @@ pub struct Repos {
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub ap_content: Arc<dyn LocalApContentQuery>,
|
||||
pub image_ref_command: Arc<dyn ImageRefCommand>,
|
||||
pub image_ref_query: Arc<dyn ImageRefQuery>,
|
||||
pub watch_event: Arc<dyn WatchEventRepository>,
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub person_query: Arc<dyn PersonQuery>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub search_port: Arc<dyn SearchPort>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub watch_event: Arc<dyn WatchEventRepository>,
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
pub ap_content: Arc<dyn LocalApContentQuery>,
|
||||
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 {
|
||||
#[cfg(feature = "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
|
||||
.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 (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone());
|
||||
let (person_command, person_query) = postgres::create_person_adapter(w.pool.clone());
|
||||
let (search_command, search_port) =
|
||||
postgres_search::create_search_adapter(pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(pool.clone());
|
||||
postgres_search::create_search_adapter(w.pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(w.pool.clone());
|
||||
let we: Arc<dyn WatchEventRepository> =
|
||||
Arc::new(postgres::PostgresWatchEventRepository::new(pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> =
|
||||
Arc::new(postgres::PostgresWebhookTokenRepository::new(pool.clone()));
|
||||
Ok((
|
||||
Repos {
|
||||
movie: m,
|
||||
review: r,
|
||||
diary: d,
|
||||
stats: s,
|
||||
user: u,
|
||||
import_session: is,
|
||||
import_profile: ip,
|
||||
movie_profile: mp,
|
||||
watchlist: wl,
|
||||
ap_content: ac,
|
||||
image_ref_command,
|
||||
image_ref_query,
|
||||
person_command,
|
||||
person_query,
|
||||
search_command,
|
||||
search_port,
|
||||
profile_fields: pf,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
},
|
||||
DbPool::Postgres(pool),
|
||||
))
|
||||
Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
|
||||
postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
|
||||
);
|
||||
Ok(WorkerDbOutput {
|
||||
movie: w.movie,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
person_command,
|
||||
person_query,
|
||||
search_command,
|
||||
search_port,
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
image_ref_command,
|
||||
image_ref_query,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
#[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
|
||||
.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());
|
||||
let pf = sqlite::create_profile_fields_repo(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(w.pool.clone());
|
||||
let (search_command, search_port) =
|
||||
sqlite_search::create_search_adapter(w.pool.clone());
|
||||
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
|
||||
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> =
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(pool.clone()));
|
||||
Ok((
|
||||
Repos {
|
||||
movie: m,
|
||||
review: r,
|
||||
diary: d,
|
||||
stats: s,
|
||||
user: u,
|
||||
import_session: is,
|
||||
import_profile: ip,
|
||||
movie_profile: mp,
|
||||
watchlist: wl,
|
||||
ap_content: ac,
|
||||
image_ref_command,
|
||||
image_ref_query,
|
||||
person_command,
|
||||
person_query,
|
||||
search_command,
|
||||
search_port,
|
||||
profile_fields: pf,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
},
|
||||
DbPool::Sqlite(pool),
|
||||
))
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
|
||||
Ok(WorkerDbOutput {
|
||||
movie: w.movie,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event: we,
|
||||
webhook_token: wt,
|
||||
person_command,
|
||||
person_query,
|
||||
search_command,
|
||||
search_port,
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
image_ref_command,
|
||||
image_ref_query,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"),
|
||||
|
||||
@@ -6,7 +6,9 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use application::{
|
||||
MovieDiscoveryIndexer, SearchCleanupHandler, config::AppConfig, context::AppContext,
|
||||
MovieDiscoveryIndexer, SearchCleanupHandler,
|
||||
config::AppConfig,
|
||||
context::{AppContext, Repositories, Services},
|
||||
worker::WorkerService,
|
||||
};
|
||||
use export::ExportAdapter;
|
||||
@@ -34,22 +36,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let image_storage = image_storage::create()?;
|
||||
|
||||
let (repos, db_pool) = db::connect(&database_url, &backend).await?;
|
||||
let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?;
|
||||
let db = db::connect(&database_url, &backend).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_query = Arc::clone(&repos.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);
|
||||
let image_ref_command = Arc::clone(&db.image_ref_command);
|
||||
let image_ref_query = Arc::clone(&db.image_ref_query);
|
||||
|
||||
// Clone refs federation handler needs before ctx consumes them.
|
||||
#[cfg(feature = "federation")]
|
||||
let (fed_ap_content, fed_user_repo, base_url, allow_registration) = (
|
||||
Arc::clone(&repos.ap_content),
|
||||
Arc::clone(&repos.user),
|
||||
Arc::clone(&db.ap_content),
|
||||
Arc::clone(&db.user),
|
||||
app_config.base_url.clone(),
|
||||
app_config.allow_registration,
|
||||
);
|
||||
@@ -63,7 +59,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
fed_social_query,
|
||||
fed_review_store,
|
||||
fed_remote_watchlist_repo,
|
||||
) = match &db_pool {
|
||||
) = match &db.db_pool {
|
||||
#[cfg(feature = "sqlite-federation")]
|
||||
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
@@ -71,34 +67,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
let ctx = AppContext {
|
||||
movie_repository: repos.movie,
|
||||
review_repository: repos.review,
|
||||
diary_repository: repos.diary,
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
stats_repository: repos.stats,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
image_storage,
|
||||
event_publisher: event_publisher_arc,
|
||||
auth_service,
|
||||
password_hasher,
|
||||
user_repository: repos.user,
|
||||
import_session_repository: repos.import_session,
|
||||
import_profile_repository: repos.import_profile,
|
||||
movie_profile_repository: repos.movie_profile,
|
||||
watchlist_repository: repos.watchlist,
|
||||
watch_event_repository: repos.watch_event,
|
||||
webhook_token_repository: repos.webhook_token,
|
||||
profile_fields_repository: Arc::clone(&profile_fields_repo),
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: fed_social_query,
|
||||
person_command: Arc::clone(&person_command),
|
||||
person_query: Arc::clone(&person_query),
|
||||
search_port: Arc::clone(&search_port),
|
||||
search_command: Arc::clone(&search_command),
|
||||
repos: Repositories {
|
||||
movie: db.movie,
|
||||
review: db.review,
|
||||
diary: db.diary,
|
||||
stats: db.stats,
|
||||
user: db.user,
|
||||
import_session: db.import_session,
|
||||
import_profile: db.import_profile,
|
||||
movie_profile: db.movie_profile,
|
||||
watchlist: db.watchlist,
|
||||
watch_event: db.watch_event,
|
||||
webhook_token: db.webhook_token,
|
||||
profile_fields: db.profile_fields,
|
||||
person_command: db.person_command,
|
||||
person_query: db.person_query,
|
||||
search_port: db.search_port,
|
||||
search_command: db.search_command,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: fed_remote_watchlist_repo.clone(),
|
||||
#[cfg(feature = "federation")]
|
||||
social_query: fed_social_query,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
password_hasher,
|
||||
metadata: metadata_client,
|
||||
poster_fetcher,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -113,10 +113,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("TMDb enrichment enabled");
|
||||
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
|
||||
enrichment_client: Arc::new(client),
|
||||
movie_repository: Arc::clone(&ctx.movie_repository),
|
||||
profile_repo: Arc::clone(&ctx.movie_profile_repository),
|
||||
person_command: Arc::clone(&ctx.person_command),
|
||||
search_command: Arc::clone(&ctx.search_command),
|
||||
movie_repository: Arc::clone(&ctx.repos.movie),
|
||||
profile_repo: Arc::clone(&ctx.repos.movie_profile),
|
||||
person_command: Arc::clone(&ctx.repos.person_command),
|
||||
search_command: Arc::clone(&ctx.repos.search_command),
|
||||
}) as Arc<dyn EventHandler>;
|
||||
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
||||
as Arc<dyn PeriodicJob>;
|
||||
@@ -131,10 +131,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
// ── Image conversion ──────────────────────────────────────────────────────
|
||||
|
||||
let conversion = image_converter::build(
|
||||
Arc::clone(&ctx.image_storage),
|
||||
Arc::clone(&ctx.services.image_storage),
|
||||
image_ref_command,
|
||||
image_ref_query,
|
||||
Arc::clone(&ctx.event_publisher),
|
||||
Arc::clone(&ctx.services.event_publisher),
|
||||
)?;
|
||||
|
||||
// ── Periodic jobs ─────────────────────────────────────────────────────────
|
||||
@@ -166,27 +166,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let handlers: Vec<Arc<dyn EventHandler>> = {
|
||||
let poster = Arc::new(poster_sync::PosterSyncHandler::new(
|
||||
Arc::clone(&ctx.movie_repository),
|
||||
Arc::clone(&ctx.metadata_client),
|
||||
Arc::clone(&ctx.poster_fetcher),
|
||||
Arc::clone(&ctx.image_storage),
|
||||
Arc::clone(&ctx.event_publisher),
|
||||
Arc::clone(&ctx.repos.movie),
|
||||
Arc::clone(&ctx.services.metadata),
|
||||
Arc::clone(&ctx.services.poster_fetcher),
|
||||
Arc::clone(&ctx.services.image_storage),
|
||||
Arc::clone(&ctx.services.event_publisher),
|
||||
3,
|
||||
)) as Arc<dyn EventHandler>;
|
||||
|
||||
let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(Arc::clone(
|
||||
&ctx.image_storage,
|
||||
&ctx.services.image_storage,
|
||||
))) as Arc<dyn EventHandler>;
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
let search_cleanup = Arc::new(SearchCleanupHandler::new(
|
||||
Arc::clone(&ctx.search_command),
|
||||
Arc::clone(&ctx.person_query),
|
||||
Arc::clone(&ctx.repos.search_command),
|
||||
Arc::clone(&ctx.repos.person_query),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(
|
||||
Arc::clone(&ctx.movie_repository),
|
||||
Arc::clone(&ctx.search_command),
|
||||
Arc::clone(&ctx.repos.movie),
|
||||
Arc::clone(&ctx.repos.search_command),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
let mut h = vec![poster, cleanup, search_cleanup, discovery_indexer];
|
||||
if let Some(e) = enrichment_handler {
|
||||
@@ -211,7 +211,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
user_repo: fed_user_repo,
|
||||
base_url,
|
||||
allow_registration,
|
||||
event_publisher: Arc::clone(&ctx.event_publisher),
|
||||
event_publisher: Arc::clone(&ctx.services.event_publisher),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -221,12 +221,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
}) as Arc<dyn EventHandler>;
|
||||
|
||||
let search_cleanup = Arc::new(SearchCleanupHandler::new(
|
||||
Arc::clone(&ctx.search_command),
|
||||
Arc::clone(&ctx.person_query),
|
||||
Arc::clone(&ctx.repos.search_command),
|
||||
Arc::clone(&ctx.repos.person_query),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
let discovery_indexer = Arc::new(MovieDiscoveryIndexer::new(
|
||||
Arc::clone(&ctx.movie_repository),
|
||||
Arc::clone(&ctx.search_command),
|
||||
Arc::clone(&ctx.repos.movie),
|
||||
Arc::clone(&ctx.repos.search_command),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
tracing::info!("federation event handler registered");
|
||||
let mut h = vec![
|
||||
|
||||
Reference in New Issue
Block a user