fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled

This commit is contained in:
2026-05-13 23:38:57 +02:00
parent 7415b91e23
commit 19171806b9
142 changed files with 4140 additions and 2025 deletions

View File

@@ -10,55 +10,57 @@ use std::str::FromStr;
use application::{
commands::{
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
AddToWatchlistCommand, RemoveFromWatchlistCommand,
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
RemoveFromWatchlistCommand, SyncPosterCommand,
},
queries::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
GetWatchlistQuery, IsOnWatchlistQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery,
IsOnWatchlistQuery, LoginQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile, update_profile_fields,
search as search_uc, get_person, get_person_credits,
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
add_to_watchlist, delete_review, export_diary as export_diary_uc,
get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person,
get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, search as search_uc, sync_poster, update_profile,
update_profile_fields,
},
};
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
models::{
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
},
services::review_history::Trend,
value_objects::{MovieId, UserId},
};
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
forms::{LogReviewData, to_diary_query},
state::AppState,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto,
CrewMemberDto, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto,
ExportQueryParams, FeedEntryDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest,
LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto,
MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams,
ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse,
SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse,
};
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, CastMemberDto, CrewMemberDto, DiaryEntryDto,
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
PersonSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, SearchQueryParams, SearchResponse,
};
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
forms::{to_diary_query, LogReviewData},
state::AppState,
};
#[utoipa::path(
get, path = "/api/v1/diary",
@@ -307,7 +309,11 @@ pub async fn get_movie_detail(
let result = get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await?;
@@ -320,13 +326,18 @@ pub async fn get_movie_detail(
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result.reviews.items.iter().map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
}).collect(),
items: result
.reviews
.items
.iter()
.map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
})
.collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
@@ -347,7 +358,12 @@ pub async fn get_movie_profile(
Path(movie_id): Path<Uuid>,
) -> impl IntoResponse {
let id = domain::value_objects::MovieId::from_uuid(movie_id);
match state.app_ctx.movie_profile_repository.get_by_movie_id(&id).await {
match state
.app_ctx
.movie_profile_repository
.get_by_movie_id(&id)
.await
{
Ok(Some(p)) => Json(MovieProfileResponse {
tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id,
@@ -360,18 +376,47 @@ pub async fn get_movie_profile(
vote_count: p.vote_count,
original_language: p.original_language,
collection_name: p.collection_name,
genres: p.genres.into_iter().map(|g| GenreDto { tmdb_id: g.tmdb_id, name: g.name }).collect(),
keywords: p.keywords.into_iter().map(|k| KeywordDto { tmdb_id: k.tmdb_id, name: k.name }).collect(),
cast: p.cast.into_iter().map(|c| CastMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, character: c.character,
billing_order: c.billing_order, profile_path: c.profile_path,
}).collect(),
crew: p.crew.into_iter().map(|c| CrewMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, job: c.job,
department: c.department, profile_path: c.profile_path,
}).collect(),
genres: p
.genres
.into_iter()
.map(|g| GenreDto {
tmdb_id: g.tmdb_id,
name: g.name,
})
.collect(),
keywords: p
.keywords
.into_iter()
.map(|k| KeywordDto {
tmdb_id: k.tmdb_id,
name: k.name,
})
.collect(),
cast: p
.cast
.into_iter()
.map(|c| CastMemberDto {
tmdb_person_id: c.tmdb_person_id,
name: c.name,
character: c.character,
billing_order: c.billing_order,
profile_path: c.profile_path,
})
.collect(),
crew: p
.crew
.into_iter()
.map(|c| CrewMemberDto {
tmdb_person_id: c.tmdb_person_id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(),
enriched_at: p.enriched_at.to_rfc3339(),
}).into_response(),
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_movie_profile: {:?}", e);
@@ -440,7 +485,11 @@ pub async fn update_profile_handler(
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
@@ -449,13 +498,19 @@ pub async fn update_profile_handler(
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
}
_ => {}
@@ -613,7 +668,11 @@ pub async fn add_blocked_domain_admin(
_admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> impl IntoResponse {
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
match state
.ap_service
.add_blocked_domain(&body.domain, body.reason.as_deref())
.await
{
Ok(()) => StatusCode::CREATED.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -656,7 +715,11 @@ pub async fn block_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.block_actor(user.0.value(), &body.actor_url).await {
match state
.ap_service
.block_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -677,7 +740,11 @@ pub async fn unblock_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await {
match state
.ap_service
.unblock_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -972,9 +1039,7 @@ pub async fn get_activity_feed(
get, path = "/api/v1/users",
responses((status = 200, body = UsersResponse)),
)]
pub async fn list_users(
State(state): State<AppState>,
) -> Result<Json<UsersResponse>, ApiError> {
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
Ok(Json(UsersResponse {
users: users
@@ -1199,31 +1264,42 @@ pub async fn get_search(
match search_uc::execute(&state.app_ctx, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results.movies.items.iter().map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
}).collect(),
items: results
.movies
.items
.iter()
.map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
})
.collect(),
total_count: results.movies.total_count,
limit: results.movies.limit,
offset: results.movies.offset,
},
people: PaginatedPersonHits {
items: results.people.items.iter().map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
}).collect(),
items: results
.people
.items
.iter()
.map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
})
.collect(),
total_count: results.people.total_count,
limit: results.people.limit,
offset: results.people.offset,
},
}).into_response(),
})
.into_response(),
Err(e) => {
tracing::error!("search failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -1251,7 +1327,8 @@ pub async fn get_person_handler(
name: person.name().to_string(),
known_for_department: person.known_for_department().map(str::to_string),
profile_path: person.profile_path().map(str::to_string),
}).into_response(),
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person failed: {e}");
@@ -1282,22 +1359,31 @@ pub async fn get_person_credits_handler(
known_for_department: credits.person.known_for_department().map(str::to_string),
profile_path: credits.person.profile_path().map(str::to_string),
},
cast: credits.cast.iter().map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
crew: credits.crew.iter().map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
}).into_response(),
cast: credits
.cast
.iter()
.map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
crew: credits
.crew
.iter()
.map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
})
.into_response(),
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person_credits failed: {e}");
@@ -1334,11 +1420,15 @@ pub async fn get_watchlist_handler(
.await?;
Ok(Json(WatchlistResponse {
items: page.items.into_iter().map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
}).collect(),
items: page
.items
.into_iter()
.map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
})
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
@@ -1394,7 +1484,10 @@ pub async fn delete_watchlist_entry(
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id },
RemoveFromWatchlistCommand {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
@@ -1416,7 +1509,10 @@ pub async fn get_watchlist_status(
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: user.0.value(), movie_id },
IsOnWatchlistQuery {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))

View File

@@ -9,6 +9,25 @@ use axum::{
use chrono::Utc;
use uuid::Uuid;
use application::{
commands::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
RemoveFromWatchlistCommand,
},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
queries::{
ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery,
},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile, update_profile_fields,
},
};
#[cfg(feature = "federation")]
use application::{
ports::{
@@ -17,29 +36,17 @@ use application::{
},
use_cases::get_remote_watchlist,
};
use application::{
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile, update_profile_fields,
},
};
use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId};
#[cfg(feature = "federation")]
use crate::forms::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::forms::{
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm,
};
use crate::{
csrf::CsrfToken,
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
state::AppState,
};
@@ -58,7 +65,10 @@ pub(crate) async fn build_page_context(
.ok()
.flatten();
let email = user.as_ref().map(|u| u.email().value().to_string());
let admin = user.as_ref().map(|u| matches!(u.role(), domain::models::UserRole::Admin)).unwrap_or(false);
let admin = user
.as_ref()
.map(|u| matches!(u.role(), domain::models::UserRole::Admin))
.unwrap_or(false);
(email, admin)
} else {
(None, false)
@@ -219,18 +229,17 @@ pub async fn post_register(
)
.await
{
Ok(_) => {
match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login").into_response(),
Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login").into_response(),
},
Err(_) => {
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
}
Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.")
.into_response(),
}
}
@@ -389,10 +398,11 @@ pub async fn get_activity_feed(
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
local_ids.push(parsed_id);
continue;
}
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
{
local_ids.push(parsed_id);
continue;
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
@@ -533,9 +543,7 @@ pub async fn get_user_profile(
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json")
|| accept.contains("application/ld+json")
{
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
return match state
.ap_service
.actor_json(&profile_user_uuid.to_string())
@@ -667,8 +675,7 @@ pub async fn get_user_profile(
.entries
.as_ref()
.map(|e| {
let has_more =
(e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
@@ -730,7 +737,11 @@ pub async fn follow_remote_user(
Err(e) => {
tracing::error!("follow error: {:?}", e);
let msg = encode_error(&e.to_string());
let sep = if redirect_base.contains('?') { '&' } else { '?' };
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response()
}
}
@@ -755,8 +766,9 @@ pub async fn unfollow_remote_user(
.unfollow(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid))
.into_response(),
Ok(()) => {
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!(
@@ -945,8 +957,9 @@ pub async fn remove_follower(
.remove_follower(user_id.value(), &form.actor_url)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid))
.into_response(),
Ok(_) => {
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!(
@@ -971,7 +984,11 @@ pub async fn get_movie_detail(
match get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await
{
@@ -982,13 +999,22 @@ pub async fn get_movie_detail(
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(result) => {
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
let has_more = result.reviews.offset + result.reviews.limit
< result.reviews.total_count as u32;
let histogram_max = result
.stats
.rating_histogram
.iter()
.copied()
.max()
.unwrap_or(1);
let has_more =
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: uid.value(), movie_id },
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,
},
)
.await
.unwrap_or(false),
@@ -1030,7 +1056,9 @@ pub async fn get_watchlist_page(
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
// Try local user first
let local_user = state.app_ctx.user_repository
let local_user = state
.app_ctx
.user_repository
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
.await
.ok()
@@ -1039,30 +1067,42 @@ pub async fn get_watchlist_page(
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
match get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) },
).await {
GetWatchlistQuery {
user_id: owner_id,
limit: Some(limit),
offset: Some(offset),
},
)
.await
{
Err(e) => {
tracing::error!("watchlist error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Ok(entries) => {
let has_more = entries.offset + entries.limit < entries.total_count as u32;
let display: Vec<WatchlistDisplayEntry> = entries.items.iter().map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w.movie.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
}).collect();
let display: Vec<WatchlistDisplayEntry> = entries
.items
.iter()
.map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w
.movie
.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
})
.collect();
(display, has_more, entries.offset, entries.limit)
}
}
@@ -1072,16 +1112,17 @@ pub async fn get_watchlist_page(
let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id)
.await
.unwrap_or_default();
let display: Vec<WatchlistDisplayEntry> = remote_entries.into_iter().map(|e| {
WatchlistDisplayEntry {
let display: Vec<WatchlistDisplayEntry> = remote_entries
.into_iter()
.map(|e| WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
}
}).collect();
})
.collect();
let len = display.len() as u32;
(display, false, 0u32, len)
}
@@ -1172,7 +1213,11 @@ pub async fn post_watchlist_add(
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::ValidationError(msg)) => {
let sep = if redirect_base.contains('?') { '&' } else { '?' };
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
Redirect::to(&url).into_response()
}
@@ -1231,12 +1276,7 @@ pub async fn get_profile_settings(
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let user = match state
.app_ctx
.user_repository
.find_by_id(&user_id)
.await
{
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
@@ -1253,7 +1293,9 @@ pub async fn get_profile_settings(
.banner_path()
.map(|path| format!("{}/images/{}", base_url, path));
let profile_fields = state.app_ctx.profile_fields_repository
let profile_fields = state
.app_ctx
.profile_fields_repository
.get_fields(&user_id)
.await
.unwrap_or_default()
@@ -1319,7 +1361,11 @@ pub async fn get_blocked_domains_page(
}
Err(e) => {
tracing::error!("get_blocked_domains error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked domains",
)
.into_response()
}
}
}
@@ -1335,7 +1381,11 @@ pub async fn post_blocked_domain(
return StatusCode::FORBIDDEN.into_response();
}
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
match state.ap_service.add_blocked_domain(&form.domain, reason).await {
match state
.ap_service
.add_blocked_domain(&form.domain, reason)
.await
{
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("add_blocked_domain error: {:?}", e);
@@ -1393,7 +1443,11 @@ pub async fn get_blocked_actors_page(
}
Err(e) => {
tracing::error!("get_blocked_actors error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked users",
)
.into_response()
}
}
}
@@ -1408,7 +1462,11 @@ pub async fn post_block_actor_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.block_actor(user_id.value(), &form.actor_url).await {
match state
.ap_service
.block_actor(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("block_actor html error: {:?}", e);
@@ -1427,7 +1485,11 @@ pub async fn post_unblock_actor(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await {
match state
.ap_service
.unblock_actor(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("unblock_actor error: {:?}", e);
@@ -1447,13 +1509,19 @@ pub async fn post_profile_settings(
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
let mut field_names: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut field_names: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
@@ -1462,26 +1530,36 @@ pub async fn post_profile_settings(
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_names.insert(idx, text); }
if !text.is_empty() {
field_names.insert(idx, text);
}
}
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_values.insert(idx, text); }
if !text.is_empty() {
field_values.insert(idx, text);
}
}
}
}
@@ -1502,10 +1580,12 @@ pub async fn post_profile_settings(
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
field_names.get(&i).map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
field_names
.get(&i)
.map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
})
.collect();

View File

@@ -1,13 +1,13 @@
use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use axum::{
Extension, Form,
extract::{Multipart, Path, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect},
};
use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use serde::Deserialize;
use std::collections::HashMap;
@@ -25,7 +25,10 @@ use application::{
list_import_profiles, save_import_profile,
},
};
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
use domain::models::{
AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, RowResult, Transform},
};
use domain::value_objects::ImportSessionId;
use crate::{
@@ -196,11 +199,10 @@ pub async fn post_upload(
)
.await
{
Ok(r) => {
Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response()
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
}
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
.into_response(),
}
}
@@ -408,8 +410,9 @@ pub async fn post_confirm(
summary.failed.len()
))
.into_response(),
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
.into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
}
}
}

View File

@@ -1,8 +1,8 @@
pub mod api;
pub mod html;
pub mod images;
pub mod rss;
pub mod api;
pub mod import;
pub mod rss;
const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;