movie detail page + importer architecture fix

This commit is contained in:
2026-05-10 23:59:26 +02:00
parent f2f1317660
commit b2a2aa4262
49 changed files with 1670 additions and 264 deletions

View File

@@ -427,6 +427,44 @@ fn default_export_format() -> String {
"csv".to_string()
}
#[derive(serde::Deserialize, Default)]
pub struct PaginationQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto {
pub total_count: u64,
pub avg_rating: Option<f64>,
pub federated_count: u64,
pub rating_histogram: [u64; 5],
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialReviewDto {
pub user_display: String,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
pub is_federated: bool,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SocialFeedResponse {
pub items: Vec<SocialReviewDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieDetailResponse {
pub movie: MovieDto,
pub stats: MovieStatsDto,
pub reviews: SocialFeedResponse,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -219,6 +219,19 @@ mod tests {
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!()
}
async fn get_movie_stats(
&self,
_: &MovieId,
) -> Result<domain::models::MovieStats, DomainError> {
panic!()
}
async fn get_movie_social_feed(
&self,
_: &MovieId,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!()
}
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
@@ -352,6 +365,15 @@ mod tests {
}
}
impl domain::ports::DocumentParser for Panic {
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
panic!()
}
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
panic!()
}
}
impl crate::ports::HtmlRenderer for Panic {
fn render_diary_page(
&self,
@@ -408,6 +430,12 @@ mod tests {
) -> 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!() }
@@ -439,6 +467,7 @@ mod tests {
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 _,

View File

@@ -13,12 +13,14 @@ use application::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
},
queries::{
GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery,
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery,
GetUsersQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, 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,
get_diary, get_movie_social_page, 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,
},
};
use domain::{
@@ -35,8 +37,10 @@ use crate::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
},
errors::ApiError,
extractors::AuthenticatedUser,
@@ -241,6 +245,51 @@ pub async fn delete_review(
}
}
#[utoipa::path(
get, path = "/api/v1/movies/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = MovieDetailResponse),
(status = 404, description = "Movie not found"),
)
)]
pub async fn get_movie_detail(
State(state): State<AppState>,
Path(movie_id): Path<Uuid>,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<MovieDetailResponse>, ApiError> {
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await?;
Ok(Json(MovieDetailResponse {
movie: movie_to_dto(&result.movie),
stats: MovieStatsDto {
total_count: result.stats.total_count,
avg_rating: result.stats.avg_rating,
federated_count: result.stats.federated_count,
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(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
},
}))
}
fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto {
id: movie.id().value(),

View File

@@ -14,11 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{
HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView,
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
RemoteActorView,
},
queries::GetMovieSocialPageQuery,
use_cases::{
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
register as register_uc,
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc,
},
};
use domain::models::ExportFormat;
@@ -916,3 +918,51 @@ pub async fn remove_follower(
}
}
}
pub async fn get_movie_detail(
OptionalCookieUser(user_id): OptionalCookieUser,
State(state): State<AppState>,
Path(movie_id): Path<uuid::Uuid>,
Query(params): Query<crate::dtos::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
)
.await
{
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(DomainError::ValidationError(_)) => StatusCode::BAD_REQUEST.into_response(),
Err(e) => {
tracing::error!("movie detail error: {:?}", e);
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 data = MovieDetailPageData {
ctx,
movie: result.movie,
stats: result.stats,
current_offset: result.reviews.offset,
has_more,
limit: result.reviews.limit,
reviews: result.reviews,
histogram_max,
};
match state.html_renderer.render_movie_detail_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
}

View File

@@ -10,7 +10,7 @@ use std::collections::HashMap;
use application::{
commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, FileFormat, SaveImportProfileCommand,
ExecuteImportCommand, SaveImportProfileCommand,
},
ports::{
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
@@ -21,8 +21,8 @@ use application::{
list_import_profiles, save_import_profile,
},
};
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
use domain::value_objects::ImportSessionId;
use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform};
use crate::{
csrf::CsrfToken,
@@ -220,7 +220,7 @@ pub async fn get_mapping_page(
else {
return Redirect::to("/import").into_response();
};
let Ok(parsed) = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) else {
let Some(parsed) = session.parsed_file else {
return Redirect::to("/import").into_response();
};
@@ -318,13 +318,8 @@ pub async fn get_preview_page(
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
let parsed =
serde_json::from_str::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session
.row_results
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let parsed = session.parsed_file.unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let rows: Vec<ImportPreviewRow> = annotated
.iter()
@@ -589,8 +584,7 @@ pub async fn api_get_session(
.await
{
Ok(Some(session)) => {
let parsed = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data)
.unwrap_or_default();
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
axum::Json(SessionStateResponse {
session_id: session_id_str,

View File

@@ -7,6 +7,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::{config::AppConfig, context::AppContext};
use export::ExportAdapter;
use importer::ImporterDocumentParser;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
@@ -14,7 +15,7 @@ use doc::ApiDocExt;
use presentation::{openapi::ApiDoc, routes, state::AppState};
use utoipa::OpenApi as _;
use domain::ports::{DiaryExporter, EventPublisher, ImportProfileRepository, ImportSessionRepository};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
@@ -150,6 +151,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
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,

View File

@@ -6,9 +6,9 @@ use utoipa::{
use crate::dtos::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse,
DirectorStatDto, FeedEntryDto, LoginRequest, LoginResponse, LogReviewRequest,
MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, ReviewDto,
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse,
MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieStatsDto,
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
};
use crate::handlers::import::{
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
@@ -40,6 +40,7 @@ impl Modify for SecurityAddon {
paths(
crate::handlers::api::get_diary,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::sync_poster,
@@ -67,6 +68,10 @@ impl Modify for SecurityAddon {
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActivityFeedResponse,
FeedEntryDto,
UsersResponse,
@@ -99,6 +104,7 @@ pub struct ApiDoc;
paths(
crate::handlers::api::get_diary,
crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_detail,
crate::handlers::api::post_review,
crate::handlers::api::delete_review,
crate::handlers::api::sync_poster,
@@ -134,6 +140,10 @@ pub struct ApiDoc;
LoginResponse,
RegisterRequest,
ReviewHistoryResponse,
MovieDetailResponse,
MovieStatsDto,
SocialFeedResponse,
SocialReviewDto,
ActorListResponse,
RemoteActorDto,
FollowRequest,

View File

@@ -50,6 +50,10 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/users/{id}",
routing::get(handlers::html::get_user_profile),
)
.route(
"/movies/{movie_id}",
routing::get(handlers::html::get_movie_detail),
)
.merge(auth)
.route(
"/reviews/new",
@@ -131,6 +135,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/movies/{id}/history",
routing::get(handlers::api::get_review_history),
)
.route(
"/movies/{id}",
routing::get(handlers::api::get_movie_detail),
)
.route("/reviews", routing::post(handlers::api::post_review))
.route(
"/reviews/{id}",