use crate::extractors::{Params, PathId}; use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState}; use api_types::{ CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, }; use application::{ diary::{ commands::SyncPosterCommand, get_movie_social_page, get_review_history, queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery}, }, movies::{get_movies, queries::GetMoviesQuery, sync_poster}, }; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use domain::services::review_history::Trend; use uuid::Uuid; #[utoipa::path( get, path = "/api/v1/movies", params(MoviesQueryParams), responses( (status = 200, body = MoviesResponse), (status = 400, description = "Invalid query", body = api_types::ErrorResponse), ) )] pub async fn list_movies( State(state): State, Params(params): Params, ) -> Result, ApiError> { let page = get_movies::execute( &state.app_ctx.deps.movies.get_movies, GetMoviesQuery { limit: params.limit, offset: params.offset, search: params.search, genre: params.genre, language: params.language, }, ) .await?; Ok(Json(MoviesResponse { items: page .items .iter() .map(api_types::mappers::summary_to_dto) .collect(), total_count: page.total_count, limit: page.limit, offset: page.offset, })) } #[utoipa::path( get, path = "/api/v1/movies/{id}/history", params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = ReviewHistoryResponse), (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ) )] pub async fn get_review_history( State(state): State, PathId(movie_id): PathId, ) -> Result, ApiError> { let (history, trend) = get_review_history::execute( &state.app_ctx.deps.diary.get_review_history, GetReviewHistoryQuery { movie_id }, ) .await?; Ok(Json(ReviewHistoryResponse { movie: api_types::mappers::movie_to_dto(history.movie()), viewings: history .viewings() .iter() .map(api_types::mappers::review_to_dto) .collect(), trend: match trend { Trend::Improved => "improved", Trend::Declined => "declined", Trend::Neutral => "neutral", } .to_string(), })) } #[utoipa::path( post, path = "/api/v1/movies/{id}/sync-poster", params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 204, description = "Poster synced"), (status = 401, description = "Unauthorized", body = api_types::ErrorResponse), (status = 404, description = "Movie not found", body = api_types::ErrorResponse), ), security(("bearer_auth" = [])) )] pub async fn sync_poster( State(state): State, _user: AuthenticatedUser, PathId(movie_id): PathId, ) -> Result { sync_poster::execute( &state.app_ctx.deps.movies.sync_poster, SyncPosterCommand { movie_id }, ) .await?; Ok(StatusCode::NO_CONTENT) } #[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", body = api_types::ErrorResponse), ) )] pub async fn get_movie_detail( State(state): State, PathId(movie_id): PathId, Params(params): Params, ) -> Result, 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.deps.diary.get_movie_social_page, GetMovieSocialPageQuery { movie_id, limit, offset, }, ) .await?; Ok(Json(MovieDetailResponse { movie: api_types::mappers::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(), user_id: e.review().user_id().value(), rating: e.review().rating().value(), comment: e.review().comment().map(|c| c.value().to_string()), watched_at: domain::value_objects::format_watched_at(e.review().watched_at()), is_federated: e.review().is_remote(), watch_medium: e.review().watch_medium().copied(), spoilers: e.review().spoilers(), }) .collect(), total_count: result.reviews.total_count, limit: result.reviews.limit, offset: result.reviews.offset, }, })) } #[utoipa::path( get, path = "/api/v1/movies/{id}/profile", params(("id" = Uuid, Path, description = "Movie ID")), responses( (status = 200, body = MovieProfileResponse), (status = 404, description = "No profile found for this movie", body = api_types::ErrorResponse), ) )] pub async fn get_movie_profile( State(state): State, PathId(movie_id): PathId, ) -> impl IntoResponse { use application::movies::get_movie_profile; let query = get_movie_profile::GetMovieProfileQuery { movie_id }; match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await { Ok(Some(result)) => { let p = result.profile; Json(MovieProfileResponse { tmdb_id: p.tmdb_id, imdb_id: p.imdb_id, overview: p.overview, tagline: p.tagline, runtime_minutes: p.runtime_minutes, budget_usd: p.budget_usd, revenue_usd: p.revenue_usd, vote_average: p.vote_average, 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: result .cast .into_iter() .map(|c| CastMemberDto { person_id: c.person_id.value().to_string(), 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: result .crew .into_iter() .map(|c| CrewMemberDto { person_id: c.person_id.value().to_string(), 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() } Ok(None) => { crate::errors::refuse(StatusCode::NOT_FOUND, crate::errors::NOT_FOUND, "not found") } Err(e) => crate::errors::ApiError(e).into_response(), } }