feat: implement movie listing functionality with pagination and search

This commit is contained in:
2026-05-12 13:57:55 +02:00
parent fb81aa10c1
commit 4269eca582
17 changed files with 239 additions and 64 deletions

View File

@@ -375,6 +375,52 @@ impl MovieRepository for PostgresRepository {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE ($1::text IS NULL OR LOWER(title) LIKE $1) \
ORDER BY title ASC \
LIMIT $2 OFFSET $3",
)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE ($1::text IS NULL OR LOWER(title) LIKE $1)",
)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]

View File

@@ -383,6 +383,54 @@ impl MovieRepository for SqliteMovieRepository {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
async fn list_movies(
&self,
page: &domain::models::collections::PageParams,
search: Option<&str>,
) -> Result<domain::models::collections::Paginated<domain::models::Movie>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = search.map(|s| format!("%{}%", s.to_lowercase()));
let rows: Vec<models::MovieRow> = sqlx::query_as(
"SELECT id, external_metadata_id, title, release_year, director, poster_path \
FROM movies \
WHERE (? IS NULL OR LOWER(title) LIKE ?) \
ORDER BY title ASC \
LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(&pattern)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(*) FROM movies WHERE (? IS NULL OR LOWER(title) LIKE ?)",
)
.bind(&pattern)
.bind(&pattern)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows.into_iter()
.map(|r| r.to_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(domain::models::collections::Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
} }
#[async_trait] #[async_trait]

View File

@@ -1,6 +1,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
// ── Movie list ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct MoviesQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
/// Optional title filter (case-insensitive substring match)
pub search: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct MoviesResponse {
pub items: Vec<MovieDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
// ── Movie profile (enrichment) ──────────────────────────────────────────────── // ── Movie profile (enrichment) ────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,5 +1,5 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::models::{ExportFormat, FieldMapping, FileFormat, UserRole}; use domain::models::{FieldMapping, FileFormat, UserRole};
use uuid::Uuid; use uuid::Uuid;
pub struct LogReviewCommand { pub struct LogReviewCommand {
@@ -21,11 +21,6 @@ pub struct SyncPosterCommand {
pub external_metadata_id: String, pub external_metadata_id: String,
} }
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RegisterCommand { pub struct RegisterCommand {
pub email: String, pub email: String,
pub username: String, pub username: String,
@@ -38,11 +33,6 @@ pub struct DeleteReviewCommand {
pub requesting_user_id: Uuid, pub requesting_user_id: Uuid,
} }
pub struct ExportCommand {
pub user_id: Uuid,
pub format: ExportFormat,
}
// FileFormat is now in domain::models — no longer defined here // FileFormat is now in domain::models — no longer defined here
pub struct CreateImportSessionCommand { pub struct CreateImportSessionCommand {
@@ -79,3 +69,10 @@ pub struct DeleteImportProfileCommand {
pub user_id: Uuid, pub user_id: Uuid,
pub profile_id: Uuid, pub profile_id: Uuid,
} }
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}

View File

@@ -226,9 +226,8 @@ mod tests {
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected") panic!("unexpected")
} }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
}
} }
#[async_trait] #[async_trait]
@@ -249,12 +248,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![]) Ok(vec![])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
} async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
} }
#[async_trait] #[async_trait]
@@ -275,12 +271,9 @@ mod tests {
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()]) Ok(vec![self.0.clone()])
} }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
panic!("unexpected") async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
} async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
} }
struct MetaReturnsMovie(Movie); struct MetaReturnsMovie(Movie);

View File

@@ -1,6 +1,16 @@
use domain::models::SortDirection; use domain::models::{ExportFormat, SortDirection};
use uuid::Uuid; use uuid::Uuid;
pub struct LoginQuery {
pub email: String,
pub password: String,
}
pub struct ExportQuery {
pub user_id: Uuid,
pub format: ExportFormat,
}
pub struct GetDiaryQuery { pub struct GetDiaryQuery {
pub limit: Option<u32>, pub limit: Option<u32>,
pub offset: Option<u32>, pub offset: Option<u32>,
@@ -70,3 +80,9 @@ pub struct GetMovieSocialPageQuery {
pub limit: u32, pub limit: u32,
pub offset: u32, pub offset: u32,
} }
pub struct GetMoviesQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
}

View File

@@ -1,13 +1,13 @@
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
use crate::{commands::ExportCommand, context::AppContext}; use crate::{context::AppContext, queries::ExportQuery};
pub async fn execute(ctx: &AppContext, cmd: ExportCommand) -> Result<Vec<u8>, DomainError> { pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> {
let entries = ctx let entries = ctx
.diary_repository .diary_repository
.get_user_history(&UserId::from_uuid(cmd.user_id)) .get_user_history(&UserId::from_uuid(query.user_id))
.await?; .await?;
ctx.diary_exporter ctx.diary_exporter
.serialize_entries(&entries, cmd.format) .serialize_entries(&entries, query.format)
.await .await
} }

View File

@@ -0,0 +1,14 @@
use domain::{
errors::DomainError,
models::collections::{PageParams, Paginated},
models::Movie,
};
use crate::{context::AppContext, queries::GetMoviesQuery};
pub async fn execute(ctx: &AppContext, query: GetMoviesQuery) -> Result<Paginated<Movie>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
ctx.movie_repository
.list_movies(&page, query.search.as_deref())
.await
}

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email}; use domain::{errors::DomainError, value_objects::Email};
use crate::{commands::LoginCommand, context::AppContext}; use crate::{context::AppContext, queries::LoginQuery};
pub struct LoginResult { pub struct LoginResult {
pub token: String, pub token: String,
@@ -12,8 +12,8 @@ pub struct LoginResult {
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> { pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> {
let email = Email::new(cmd.email)?; let email = Email::new(query.email)?;
let user = ctx let user = ctx
.user_repository .user_repository
.find_by_email(&email) .find_by_email(&email)
@@ -22,7 +22,7 @@ pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult,
let valid = ctx let valid = ctx
.password_hasher .password_hasher
.verify(&cmd.password, user.password_hash()) .verify(&query.password, user.password_hash())
.await?; .await?;
if !valid { if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".into())); return Err(DomainError::Unauthorized("Invalid credentials".into()));

View File

@@ -11,6 +11,7 @@ pub mod export_diary;
pub mod get_activity_feed; pub mod get_activity_feed;
pub mod get_diary; pub mod get_diary;
pub mod get_movie_social_page; pub mod get_movie_social_page;
pub mod get_movies;
pub mod get_review_history; pub mod get_review_history;
pub mod get_user_profile; pub mod get_user_profile;
pub mod get_users; pub mod get_users;

View File

@@ -4,14 +4,7 @@ use domain::{
value_objects::UserId, value_objects::UserId,
}; };
use crate::context::AppContext; use crate::{commands::UpdateProfileCommand, context::AppContext};
pub struct UpdateProfileCommand {
pub user_id: uuid::Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> { pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);

View File

@@ -8,7 +8,7 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, AnnotatedRow, DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping,
FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats, FileFormat, ImportError, ImportProfile, ImportSession, Movie, MovieProfile, MovieStats,
ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends, ParsedFile, Review, ReviewHistory, User, UserStats, UserSummary, UserTrends,
collections::{PageParams, Paginated}, collections::{self, PageParams, Paginated},
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -83,6 +83,11 @@ pub trait MovieRepository: Send + Sync {
) -> Result<Vec<Movie>, DomainError>; ) -> Result<Vec<Movie>, DomainError>;
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>; async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
async fn list_movies(
&self,
page: &collections::PageParams,
search: Option<&str>,
) -> Result<collections::Paginated<Movie>, DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -177,6 +177,9 @@ mod tests {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!() panic!()
} }
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl ReviewRepository for Panic { impl ReviewRepository for Panic {

View File

@@ -10,15 +10,15 @@ use std::str::FromStr;
use application::{ use application::{
commands::{ commands::{
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand, DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
}, },
queries::{ queries::{
GetActivityFeedQuery, GetMovieSocialPageQuery, GetReviewHistoryQuery, GetUserProfileQuery, ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetUsersQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
}, },
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_review_history, 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, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile, register as register_uc, sync_poster, update_profile,
}, },
@@ -40,9 +40,9 @@ use api_types::{
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserSummaryDto, UserTrendsDto, UsersResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
}; };
use crate::{ use crate::{
errors::ApiError, errors::ApiError,
@@ -74,6 +74,35 @@ pub async fn get_diary(
})) }))
} }
#[utoipa::path(
get, path = "/api/v1/movies",
params(MoviesQueryParams),
responses(
(status = 200, body = MoviesResponse),
)
)]
pub async fn list_movies(
State(state): State<AppState>,
Query(params): Query<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
&state.app_ctx,
GetMoviesQuery {
limit: params.limit,
offset: params.offset,
search: params.search,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page.items.iter().map(movie_to_dto).collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path( #[utoipa::path(
get, path = "/api/v1/movies/{id}/history", get, path = "/api/v1/movies/{id}/history",
params(("id" = Uuid, Path, description = "Movie ID")), params(("id" = Uuid, Path, description = "Movie ID")),
@@ -179,7 +208,7 @@ pub async fn login(
) -> Result<Json<LoginResponse>, ApiError> { ) -> Result<Json<LoginResponse>, ApiError> {
let result = login_uc::execute( let result = login_uc::execute(
&state.app_ctx, &state.app_ctx,
LoginCommand { LoginQuery {
email: req.email, email: req.email,
password: req.password, password: req.password,
}, },
@@ -415,7 +444,7 @@ pub async fn update_profile_handler(
} }
} }
let cmd = update_profile::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
bio, bio,
avatar_bytes, avatar_bytes,
@@ -1036,11 +1065,11 @@ pub async fn export_diary(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"), ExportFormat::Json => ("application/json", "diary.json"),
}; };
let cmd = ExportCommand { let query = ExportQuery {
user_id: user.0.value(), user_id: user.0.value(),
format, format,
}; };
match export_diary_uc::execute(&state.app_ctx, cmd).await { match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => ( Ok(bytes) => (
StatusCode::OK, StatusCode::OK,
[ [

View File

@@ -15,12 +15,12 @@ use application::ports::{
FollowersPageData, FollowingPageData, FollowersPageData, FollowingPageData,
}; };
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, RegisterCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
ports::{ ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, ProfileSettingsPageData, RegisterPageData, RemoteActorView,
}, },
queries::GetMovieSocialPageQuery,
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc, update_profile, login as login_uc, register as register_uc, update_profile,
@@ -133,7 +133,7 @@ pub async fn post_login(
} }
match login_uc::execute( match login_uc::execute(
&state.app_ctx, &state.app_ctx,
LoginCommand { LoginQuery {
email: form.email, email: form.email,
password: form.password, password: form.password,
}, },
@@ -215,7 +215,7 @@ pub async fn post_register(
.await .await
{ {
Ok(_) => { Ok(_) => {
match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await { match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => { Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age); let cookie = set_cookie_header(&result.token, max_age);
@@ -320,11 +320,11 @@ pub async fn get_export(
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
ExportFormat::Json => ("application/json", "diary.json"), ExportFormat::Json => ("application/json", "diary.json"),
}; };
let cmd = ExportCommand { let query = ExportQuery {
user_id: user_id.value(), user_id: user_id.value(),
format, format,
}; };
match export_diary_uc::execute(&state.app_ctx, cmd).await { match export_diary_uc::execute(&state.app_ctx, query).await {
Ok(bytes) => ( Ok(bytes) => (
StatusCode::OK, StatusCode::OK,
[ [
@@ -1230,7 +1230,7 @@ pub async fn post_profile_settings(
} }
} }
let cmd = update_profile::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
bio, bio,
avatar_bytes, avatar_bytes,

View File

@@ -1,20 +1,30 @@
use api_types::{ use api_types::{
DirectorStatDto, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, CastMemberDto, CrewMemberDto, DirectorStatDto, GenreDto, KeywordDto, MonthActivityDto,
MovieStatsDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserTrendsDto, MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserTrendsDto,
}; };
use utoipa::OpenApi; use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
crate::handlers::api::list_movies,
crate::handlers::api::get_movie_detail, crate::handlers::api::get_movie_detail,
crate::handlers::api::get_review_history, crate::handlers::api::get_review_history,
crate::handlers::api::get_movie_profile,
crate::handlers::api::sync_poster, crate::handlers::api::sync_poster,
), ),
components(schemas( components(schemas(
MoviesResponse,
MovieDto, MovieDto,
MovieDetailResponse, MovieDetailResponse,
MovieStatsDto, MovieStatsDto,
MovieProfileResponse,
GenreDto,
KeywordDto,
CastMemberDto,
CrewMemberDto,
ReviewHistoryResponse, ReviewHistoryResponse,
SocialFeedResponse, SocialFeedResponse,
SocialReviewDto, SocialReviewDto,

View File

@@ -177,6 +177,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/movies/{id}/history", "/movies/{id}/history",
routing::get(handlers::api::get_review_history), routing::get(handlers::api::get_review_history),
) )
.route("/movies", routing::get(handlers::api::list_movies))
.route( .route(
"/movies/{id}", "/movies/{id}",
routing::get(handlers::api::get_movie_detail), routing::get(handlers::api::get_movie_detail),