feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation
This commit is contained in:
@@ -10,11 +10,13 @@ use std::str::FromStr;
|
||||
|
||||
use application::{
|
||||
commands::{
|
||||
DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
|
||||
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
|
||||
AddToWatchlistCommand, RemoveFromWatchlistCommand,
|
||||
},
|
||||
queries::{
|
||||
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
|
||||
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
|
||||
GetWatchlistQuery, IsOnWatchlistQuery,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||
@@ -22,11 +24,12 @@ use application::{
|
||||
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
||||
register as register_uc, sync_poster, update_profile,
|
||||
search as search_uc, get_person, get_person_credits,
|
||||
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
|
||||
},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams},
|
||||
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
|
||||
services::review_history::Trend,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
@@ -44,6 +47,7 @@ use api_types::{
|
||||
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
|
||||
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
|
||||
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest,
|
||||
};
|
||||
use api_types::search::{
|
||||
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
|
||||
@@ -96,12 +100,14 @@ pub async fn list_movies(
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search: params.search,
|
||||
genre: params.genre,
|
||||
language: params.language,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MoviesResponse {
|
||||
items: page.items.iter().map(movie_to_dto).collect(),
|
||||
items: page.items.iter().map(summary_to_dto).collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
@@ -438,12 +444,11 @@ pub async fn update_profile_handler(
|
||||
}
|
||||
"avatar" => {
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty() {
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = content_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -476,6 +481,26 @@ fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||
release_year: movie.release_year().value(),
|
||||
director: movie.director().map(|d| d.to_string()),
|
||||
poster_path: movie.poster_path().map(|p| p.value().to_string()),
|
||||
genres: vec![],
|
||||
runtime_minutes: None,
|
||||
original_language: None,
|
||||
overview: None,
|
||||
collection_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_to_dto(summary: &MovieSummary) -> MovieDto {
|
||||
MovieDto {
|
||||
id: summary.movie.id().value(),
|
||||
title: summary.movie.title().value().to_string(),
|
||||
release_year: summary.movie.release_year().value(),
|
||||
director: summary.movie.director().map(|d| d.to_string()),
|
||||
poster_path: summary.movie.poster_path().map(|p| p.value().to_string()),
|
||||
genres: summary.genres.clone(),
|
||||
runtime_minutes: summary.runtime_minutes,
|
||||
original_language: summary.original_language.clone(),
|
||||
overview: summary.overview.clone(),
|
||||
collection_name: summary.collection_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1233,3 +1258,119 @@ pub async fn get_person_credits_handler(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist",
|
||||
params(
|
||||
("limit" = Option<u32>, Query, description = "Max results"),
|
||||
("offset" = Option<u32>, Query, description = "Offset"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = WatchlistResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_handler(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
GetWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WatchlistResponse {
|
||||
items: page.items.into_iter().map(|w| WatchlistEntryDto {
|
||||
id: w.entry.id.value(),
|
||||
movie: movie_to_dto(&w.movie),
|
||||
added_at: w.entry.added_at.to_string(),
|
||||
}).collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/watchlist",
|
||||
request_body = AddToWatchlistRequest,
|
||||
responses(
|
||||
(status = 201, description = "Added to watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_watchlist_add(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
add_to_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
input: MovieInput {
|
||||
movie_id: Some(req.movie_id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Removed from watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not on watchlist"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_watchlist_entry(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = WatchlistStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_status(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
IsOnWatchlistQuery { user_id: user.0.value(), movie_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WatchlistStatusResponse { on_watchlist }))
|
||||
}
|
||||
|
||||
@@ -15,15 +15,17 @@ use application::ports::{
|
||||
FollowersPageData, FollowingPageData,
|
||||
};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, RegisterCommand},
|
||||
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
|
||||
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
|
||||
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, LoginQuery},
|
||||
ports::{
|
||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
|
||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
|
||||
WatchlistPageData,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
||||
login as login_uc, register as register_uc, update_profile,
|
||||
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
||||
get_watchlist, log_review, login as login_uc, register as register_uc,
|
||||
remove_from_watchlist, update_profile,
|
||||
},
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
@@ -383,12 +385,11 @@ pub async fn get_activity_feed(
|
||||
let mut local_ids = vec![uid.value()];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
|
||||
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(domain::ports::FollowingFilter {
|
||||
@@ -953,7 +954,7 @@ pub async fn get_movie_detail(
|
||||
Query(params): Query<api_types::PaginationQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
@@ -973,10 +974,19 @@ pub async fn get_movie_detail(
|
||||
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
|
||||
let has_more = result.reviews.offset + result.reviews.limit
|
||||
< result.reviews.total_count as u32;
|
||||
let on_watchlist = match &user_id {
|
||||
Some(uid) => state.app_ctx.watchlist_repository
|
||||
.contains(uid, &domain::value_objects::MovieId::from_uuid(movie_id))
|
||||
.await
|
||||
.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
let data = MovieDetailPageData {
|
||||
ctx,
|
||||
movie: result.movie,
|
||||
stats: result.stats,
|
||||
profile: result.profile,
|
||||
on_watchlist,
|
||||
current_offset: result.reviews.offset,
|
||||
has_more,
|
||||
limit: result.reviews.limit,
|
||||
@@ -994,6 +1004,206 @@ pub async fn get_movie_detail(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_watchlist_page(
|
||||
OptionalCookieUser(viewer_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(owner_id): Path<uuid::Uuid>,
|
||||
Query(params): Query<crate::forms::WatchlistQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
||||
|
||||
// Try local user first
|
||||
let local_user = state.app_ctx.user_repository
|
||||
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
|
||||
match get_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) },
|
||||
).await {
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist error: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
Ok(entries) => {
|
||||
let has_more = entries.offset + entries.limit < entries.total_count as u32;
|
||||
let display: Vec<WatchlistDisplayEntry> = entries.items.iter().map(|w| {
|
||||
let remove_url = if is_owner {
|
||||
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: w.movie.poster_path()
|
||||
.map(|p| format!("/images/{}", p.value())),
|
||||
movie_title: w.movie.title().value().to_string(),
|
||||
release_year: w.movie.release_year().value(),
|
||||
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
|
||||
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url,
|
||||
}
|
||||
}).collect();
|
||||
(display, has_more, entries.offset, entries.limit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let remote_entries = state.app_ctx.remote_watchlist_repository
|
||||
.get_by_derived_uuid(owner_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let display: Vec<WatchlistDisplayEntry> = remote_entries.into_iter().map(|e| {
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: e.poster_url,
|
||||
movie_title: e.movie_title,
|
||||
release_year: e.release_year,
|
||||
movie_url: None,
|
||||
added_at: e.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url: None,
|
||||
}
|
||||
}).collect();
|
||||
let len = display.len() as u32;
|
||||
(display, false, 0u32, len)
|
||||
}
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
(vec![], false, 0u32, 0u32)
|
||||
}
|
||||
};
|
||||
|
||||
let data = WatchlistPageData {
|
||||
ctx,
|
||||
owner_id,
|
||||
display_entries,
|
||||
current_offset,
|
||||
has_more,
|
||||
limit: page_limit,
|
||||
is_owner,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_watchlist_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist template error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_watchlist_add(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<crate::forms::WatchlistAddForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
let redirect_base = form
|
||||
.redirect_after
|
||||
.as_deref()
|
||||
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
|
||||
let input = if let Some(id) = form.movie_id {
|
||||
MovieInput {
|
||||
movie_id: Some(id),
|
||||
external_metadata_id: None,
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
}
|
||||
} else {
|
||||
let query = form.query.as_deref().unwrap_or("").trim().to_string();
|
||||
let is_external_id = query.starts_with("tmdb:")
|
||||
|| (query.starts_with("tt")
|
||||
&& query.len() > 2
|
||||
&& query[2..].chars().all(|c| c.is_ascii_digit()));
|
||||
if is_external_id {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: Some(query),
|
||||
manual_title: None,
|
||||
manual_release_year: None,
|
||||
manual_director: None,
|
||||
}
|
||||
} else {
|
||||
MovieInput {
|
||||
movie_id: None,
|
||||
external_metadata_id: None,
|
||||
manual_title: if query.is_empty() { None } else { Some(query) },
|
||||
manual_release_year: form.year,
|
||||
manual_director: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match add_to_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user_id.value(),
|
||||
input,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to(&redirect_base).into_response(),
|
||||
Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(),
|
||||
Err(DomainError::ValidationError(msg)) => {
|
||||
let sep = if redirect_base.contains('?') { '&' } else { '?' };
|
||||
let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
|
||||
Redirect::to(&url).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist add error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_watchlist_remove(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(movie_id): Path<uuid::Uuid>,
|
||||
Form(form): Form<crate::forms::DeleteRedirectForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match remove_from_watchlist::execute(
|
||||
&state.app_ctx,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user_id.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) | Err(DomainError::NotFound(_)) => {
|
||||
let redirect_url = form
|
||||
.redirect_after
|
||||
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
Redirect::to(&redirect_url).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("watchlist remove error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct SavedQuery {
|
||||
pub saved: Option<String>,
|
||||
@@ -1219,12 +1429,11 @@ pub async fn post_profile_settings(
|
||||
}
|
||||
"avatar" => {
|
||||
let content_type = field.content_type().map(|s| s.to_string());
|
||||
if let Ok(bytes) = field.bytes().await {
|
||||
if !bytes.is_empty() {
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty() {
|
||||
avatar_bytes = Some(bytes.to_vec());
|
||||
avatar_content_type = content_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user